可观测性层:结构化日志与调用链追踪的最小实现
引言
聊起可观测性,大家的第一反应往往是 Datadog、Prometheus、Jaeger 这一套分布式全家桶。但并不是每个系统都需要它们:AgentHub 是一个跑在用户桌面上的单机 Agent 网关(Tauri sidecar),日志天然就在一台机器上,排障的人就坐在这台机器前。为这种形态引入 metrics pipeline、时序库和告警引擎,买的是用不上的分布式能力。
所以它的可观测性层是一套刻意轻量的本地实现,两条线:结构化 JSON 日志 (logger,一条日志同时去 stdout/stderr、日志文件、WebSocket 实时流三处)+ 调用链追踪(Tracer,挂在 Agent Loop 上按轮记录)。Tracer 的产出不是独立系统,而是以 debug 级别日志的形式嵌进 logger。它不追求企业级 APM 那套 metrics/外部聚合/链路可视化/告警,只解决"单机桌面应用本地排障够用"这个具体问题。本文讲这两条线的设计,以及"哪些刻意不做"背后的判断。
一、为什么日志要三个去向,而不是三选一
logger 的 write 函数(src/observability/logger.ts)里,一条日志会被写往三个地方:
arduino
function write(level, message, fields?): void {
if (LEVELS[level] < LEVELS[currentLevel]) return; // 级别过滤
const entry = JSON.stringify({ ts, level, msg, ...fields }); // 结构化 JSON
if (level === "warn" || level === "error") {
process.stderr.write(entry + "\n"); // 去向 1a:stderr
} else {
process.stdout.write(entry + "\n"); // 去向 1b:stdout
}
if (logFilePath) {
appendFileSync(logFilePath, entry + "\n"); // 去向 2:日志文件
}
if (broadcaster) {
broadcaster.broadcast(entry); // 去向 3:WebSocket 实时流
}
}
关键设计意图是:三个去向消费的是同一条 JSON entry,内容完全一致,区别只在消费方式。
| 去向 | 谁消费 | 解决什么 |
|---|---|---|
| stdout/stderr | 进程外(打包时被 Rust spawn 重定向到工作目录根的 sidecar-debug.log) | 进程级排障:能抓到 logger 之外的输出,如未捕获的 console.log、Bun 启动信息、崩溃前最后一行 |
| 日志文件 | gateway.log / cli.log / serve.log(工作目录 logs/ 下,启动模式决定写哪个) | 业务日志持久化,只含 logger 的结构化输出,易过滤 |
| WS 实时流 | 桌面端"实时日志"页 | 前端不落地实时看,不用去翻文件 |
之所以不做成三选一,是因为这三个消费场景的需求正交:进程外要抓全量原始流(含非结构化输出)、文件要干净可 grep 的持久化、前端要零延迟的实时推送。用同一份 entry 扇出三路,保证了"桌面端看到的 = 文件里的 = stdout 里的",排障时不会出现"前端和日志文件对不上"的困惑。代价是每条日志三次写,但对单机桌面应用的日志量级这个成本可忽略。
二、结构化 JSON 日志:让日志变成可查询的数据
每条日志是一个 JSON 对象,而不是拼接好的字符串:
javascript
const entry = JSON.stringify({
ts: new Date().toISOString(), // ISO 时间戳
level, // debug/info/warn/error
msg: message, // 人类可读消息
...fields, // 任意结构化字段(对象展开)
});
实际调用长这样,业务字段全部字段化、不塞进 msg:
php
logger.info("agent turn complete", {
session_key: msg.session_key,
provider: this.provider.name,
model: this.provider.model,
input_tokens: response.input_tokens,
output_tokens: response.output_tokens,
duration_ms: durationMs,
content: response.content.slice(0, 500),
});
logger.warn("session lock timed out, force releasing", {
session_key: key,
message_id,
});
结构化的核心价值在于日志变成可查询的数据:
- 排障时能用
grep '"session_key":"my_agent:wecom:zhangsan"'精确捞出某个会话某个 agent 的全部日志------多 agent 架构下 session_key 格式是agent:channel:chat_id(单 agent 时代是channel:chat_id),前缀多了一段 agent 名,过滤时要带上; - 能按 level 过滤、按 duration_ms 排序找慢请求;
- 前端实时日志页能机器解析,高亮 level、折叠 fields。
对比非结构化的 log("agent turn complete for wecom:zhangsan took 5000ms"),信息全糊在字符串里,过滤和分析都得靠正则硬抠。选 JSON 而不是纯文本,本质是拿"写的时候多一步 stringify"换"读的时候能结构化查询"。
级别过滤是四级递增门槛:
ini
const LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
let currentLevel: LogLevel = "info"; // 默认 info
// write 里:LEVELS[level] < LEVELS[currentLevel] 就丢弃
currentLevel 是当前最低输出级别,由配置 observability.log_level 决定,且可热更新 ------loadConfig 末尾直接调 logger.setLevel(...),reloadConfig 后立即生效,不用重启。这一点和 Tracer 的关系很关键:Tracer 的 record 用的是 debug 级别,所以生产环境默认 info 时 trace 根本不输出,只有排障时手动把 log_level 调到 debug 才看得到调用链。这是一个刻意的成本控制------trace 数据量大,平时不产,要看时才开。
三、Tracer:挂在 Agent Loop 上的调用链追踪
Tracer(src/observability/tracer.ts)一个实例对应一轮对话的调用链,record 每次生成一个带步号的事件:
typescript
export class Tracer {
private step = 0;
constructor(
private readonly trace_id: string,
private readonly session_key: string,
) {}
record(type: TraceEventType, started_at: number, details?): void {
const event: TraceEvent = {
trace_id: this.trace_id,
session_key: this.session_key,
step: this.step++, // ★ 递增步号,标记事件顺序
type,
started_at,
duration_ms: Date.now() - started_at, // 从轮开始算的耗时,不是事件自身耗时
details,
};
logger.debug("trace", { trace: event }); // 走 logger debug,不是独立通道
}
}
挂载点在 Agent Loop 的每一轮(src/agent/loop.ts 的 process;多 agent 演进后,跑这个 loop 的是该 agent 自己的 AgentLoop,不再是全局单例,但 Tracer 的挂载机制本身没变):
csharp
// AgentLoop.process
const started = Date.now();
const tracer = new Tracer(randomUUID(), msg.session_key); // 每轮一个 trace_id
// ...
tracer.record("llm_call", started, { message_count });
// ...
tracer.record("complete", started, { duration_ms, input_tokens, output_tokens });
有三个设计点值得展开:
1. duration_ms 从轮开始算,不是事件自身耗时。 每个事件的 duration_ms 都是"相对于这一轮开始时刻的偏移":llm_call 的 duration_ms = 从轮开始到调 LLM 的耗时(即组装 system prompt + 处理历史文件块的耗时),complete 的 duration_ms = 整轮总耗时。这样把一串事件按 step 排开,duration_ms 就是一条时间轴------每个点标出"发生在轮开始后第几毫秒",拼起来就是调用链的时间线,不用再做减法。
2. 定义五种事件类型,实际只记三种。 类型定义是 "llm_call" | "tool_call" | "tool_result" | "error" | "complete",但 Loop 里只 record 了 llm_call、complete、error。tool_call/tool_result 定义了但没接通------Tracer 没在 Runner 或 ToolRegistry 里被调用。这是个诚实的不彻底处:设计上想追踪每步工具调用,代码没落地。所以实际的 trace 只有两三个事件:
ini
一轮对话的 trace:
step 0: llm_call (开始调 LLM,duration = prompt 组装耗时)
step 1: complete (LLM 返回,duration = 整轮耗时)
出错时:
step 0: llm_call
step 1: error (duration = 出错时的耗时)
3. 粒度是"整轮",不是"每个 ReAct 步"。 ReAct 循环内部可能调多次 LLM、多次工具,但 Tracer 只在 Loop 外层 record 一次 llm_call,Runner 内部的每次 LLM/工具调用都不单独 trace。这跟"ReAct 循环的中间步骤不进 JSONL"是同一套取舍------中间的 tool_use/tool_result 既不持久化也不进 trace,只保留整轮的"开始调 LLM → 完成/出错"骨架。对单机排障来说,整轮粒度足够定位"哪一轮慢了/出错了",逐步追踪的收益不值那个复杂度。
补一个流式改造后的现状:SSE 流式改造(见 12 号笔记)之后 Tracer 完全没动------挂载点仍是 Loop 里那三处 record(llm_call/complete/error),事件类型和产出通道都没变;流式 token 只走 sink → SSE 推到前端,不产生任何 token 级 trace 事件,trace 仍然只有整轮骨架那两三个事件。
trace_id 和 session_key 是两个不同粒度的维度:
| 字段 | 粒度 | 一对多 |
|---|---|---|
| session_key | 会话(多 agent 下是 agent:channel:chat_id) |
一个会话多轮对话 |
| trace_id | 单轮 | 一轮一个 trace_id |
排障时 session_key 过滤某会话的所有轮,trace_id 精确到某一轮,两者组合能定位"某会话某轮出了什么"。
四、WS 实时流:发布-订阅的极简实现
第三个去向 broadcaster 由 HttpChannel 里的 LogStreamBroadcaster(src/channels/http/log-stream.ts)实现:
csharp
export class LogStreamBroadcaster {
private clients = new Set<WsClient>();
add(ws: WsClient): void { this.clients.add(ws); }
remove(ws: WsClient): void { this.clients.delete(ws); }
broadcast(entry: string): void {
for (const ws of this.clients) {
try {
ws.send(entry);
} catch {
this.clients.delete(ws); // send 失败(客户端已断)-> 移除,防僵尸连接
}
}
}
get size(): number { return this.clients.size; } // 当前连接数,主要给测试断言用
}
两个小设计:用 Set 不用数组 ------WS 连接天然唯一,Set 自动去重且 remove 是 O(1);broadcast 里 catch 即删------某个 ws.send 抛错说明客户端断了但 close 事件还没到,直接从集合剔除,避免僵尸连接每次 broadcast 都报错堆积。
开关 log_stream_enabled 控制这条流开不开,拦截点在 /ws/logs 路由的升级前,而且升级前实际有两道拦截(src/channels/http/index.ts):
kotlin
if (req.method === "GET" && url.pathname === "/ws/logs") {
if (!this.logStreamEnabled) {
return Response.json({ error: "log streaming disabled" }, { status: 403 });
}
// WS 不走 CORS/Same-Origin 策略,浏览器跨站可直接连------校验 Origin 白名单
const wsOrigin = req.headers.get("origin");
if (wsOrigin && !CORS_ALLOWED_ORIGINS.has(wsOrigin)) {
return Response.json({ error: "origin not allowed" }, { status: 403 });
}
const upgraded = server.upgrade(req);
// ...
}
第二道 Origin 白名单校验防的是恶意网页跨站直连 WS 读日志:WS 握手不受浏览器 CORS/Same-Origin 策略约束,光靠 CORS 拦不住,必须在升级前自己验 Origin------白名单(与 HTTP CORS 同一份:桌面 webview 的 origin 加本地 dev server)放行、无 Origin(curl 等非浏览器客户端)放行、其余 403。
这里有个容易误解的点:Bun.serve 的 websocket.open/close 回调是协议层配置,总是注册的;但"回调总注册"不等于"日志流总开着"------能不能真正升级连接由 fetch 里的 server.upgrade 决定,而 upgrade 前有 logStreamEnabled 的 403 拦截。所以开关是在路由层生效的,配置项功能完整不是摆设。
另外一个边界要交代:logStreamEnabled 是 HttpChannel 构造时注入的参数(启动时从 config 读一次),不在热更名单里------改这项配置要重启网关才生效。这也符合"开关类配置在连接建立点生效"的一般规律。
五、和成熟 APM/metrics 系统的定位差异
可观测性层刻意不做的事:
| 事 | 状态 | 缺什么 |
|---|---|---|
| 记日志 / 追踪调用链 / 级别过滤 / 文件持久化 / WS 推送 | 已实现 | - |
| 收集指标(metrics) | 没做 | 没有 QPS、成功率、p99 延迟这类聚合指标 |
| 聚合到外部系统 | 没做 | 日志只落本地文件 + WS,没接 ELK / Prometheus |
| trace 可视化 | 没做 | trace 只是 debug 日志里的 JSON,没有 Jaeger 那样的链路 UI |
| 告警 | 没做 | error 日志只落文件,不触发邮件/IM 通知 |
定位差异的本质是部署形态决定的:Datadog/Prometheus 那套是为分布式、多实例、需要跨机聚合和主动告警的服务端系统设计的,它们的价值在于"把散落在多个节点的信号收拢到一个中心,做时序聚合和阈值告警"。而 AgentHub 是单机桌面应用(Tauri sidecar),日志天然就在一台机器上,排障的人就坐在这台机器前------不存在"跨节点收拢"的问题,也就不需要为此付出 metrics pipeline、时序库、告警规则引擎的复杂度和运维成本。
所以选择是:日志用本地文件 + WS 流,够排障;追踪挂在 Loop 上、debug 级别输出,够看单轮调用链;metrics/聚合/可视化/告警全部不做。这不是能力缺失,是对"单机桌面应用的可观测性到底需要多重"的判断------用最小实现覆盖真实需求,不为用不上的分布式场景买单。
六、局限与演进方向
三个已知局限:
- trace 的 tool_call/tool_result 定义了没接通,工具调用维度追踪不到。要补的话,得把 Tracer 实例透传进 Runner,在每次工具 dispatch 前后 record 一对------改动小、收益直接,属于优先级排后的坑而不是 bug。
- trace 粒度只到整轮,ReAct 内部多次 LLM/工具调用看不到明细,遇到"一轮里到底哪一步慢"这类问题追不下去。
- 整体没有 metrics 和告警,error 日志只落文件不主动通知,靠人翻日志才能发现问题------对单机自用够,但如果哪天要无人值守长期运行,这个盲区会暴露。
演进路径也顺着需求逐级加 :最小改动是接通 tool_call/tool_result,让 trace 从整轮粒度细化到 ReAct 步;往一层是轻量 metrics------在现有 logger 基础上做内存计数器(每轮成功/失败、token、duration 累加),暴露一个 /api/metrics 给桌面端画简单趋势图,不必上 Prometheus;再往上如果真要无人值守,才考虑告警------比如 error 日志达到阈值时通过已有的邮件工具或 IM 渠道推一条通知。原则是顺着"单机 → 需要主动感知"的实际需求逐级加,而不是一上来套整套分布式 APM。
小结
AgentHub 的可观测性层用两条挂在一起的线覆盖单机排障的全部需求:logger 把结构化 JSON 日志扇出到 stdout/stderr、文件、WS 三处(四级级别可热更新),Tracer 挂在 per-agent 的 Agent Loop 上按整轮记录调用链、以 debug 日志形式嵌进 logger;它诚实地不做 metrics/外部聚合/trace UI/告警,把"够单机排障用"这个具体问题解到位。这套实现给出的可复用经验是:可观测性的重量级应该跟部署形态走------单机桌面应用要的是"三路同源的日志扇出 + 整轮级 trace 骨架 + 零依赖",而不是分布式 APM 的重型基础设施;同时接受不彻底(工具级 trace 未接通),把省下的复杂度留给真正的需求出现时再补。