可观测栈总览
日志由 pino 直推 Loki、trace 由 @ai-sdk/otel 直推 Tempo——路径里没有 collector;外加决定 Loki 成本的 label cardinality 陷阱,以及三个把 collector(Alloy)拉进路径的信号
为什么把可观测当作第一等公民
Agent 系统的 bug 有一类特别隐蔽——行为上对、但经济上不对。LLM 依然返回了合理的答案,测试用例依然通过,但每一步都在重复 cache miss、每次压缩都比应该的更激进、每个 tool 调用都在掉 25% 的浮动预算。肉眼看不到,只能靠仪表盘暴露。
本章介绍 Zapvol 现在走的方案——两条直推进 Grafana Cloud:日志走 pino → Loki、trace 走
@ai-sdk/otel → Tempo,中间没有 collector——以及为什么这么连、什么时候 collector 才值得引入、陷阱在哪。不是部署手册,是运维这个系统需要的心智模型。
管道全景
今天没有 Alloy / collector 这一跳:pino-loki transport 在后台 worker 线程把日志推给 Loki,OTLP
exporter 在后台定时器把 span 推给 Tempo。两者都是异步批量,导出路径从不压在请求上。图里的 collector 层是以后毕业进入的形态(见
collector 何时才值得引入),不是今天在跑的东西。
管道 SVG 仍画着旧的 collector 拓扑,欠一次重绘以对齐当前的直连形态。
这套组合——全部开源、标准协议、Grafana Cloud 免费档、横向扩展路径清晰——依然综合性价比最高;跟图相比唯一的变化是:collector 推迟到有具体信号要求时再上。
为什么选这套——三个候选方案的对比
候选 1:ELK(Elasticsearch + Logstash + Kibana)
历史最悠久,功能最全。对 Zapvol 这种规模太重:
- Elasticsearch 是全文索引引擎,每个字段都建倒排索引。日志场景下 90% 字段从不被查询,索引成本全白烧
- Logstash 的 JRuby 运行时内存占用是 Alloy 的 5-10 倍
- Kibana 的权限模型复杂度远超单团队需要
结论:适合做全文搜索(“grep everything in prod”)场景,Zapvol 的日志查询都是结构化的(按 event、taskId、时间段过滤),ES 的强项派不上用场。
候选 2:OpenTelemetry Collector + 任意后端
最标准化。但 Alloy 本身就是基于 OTel Collector 的发行版,两者的差异主要在:
- Alloy 带 River 配置语言 + 可视化调试 UI(
:12345) - Alloy 对 Grafana 全家桶有开箱即用的最佳实践
- OTel Collector 通用性更强但配置更原始
结论:Zapvol 今天不跑任何 collector(两条信号都直连)。将来真需要一个时,若不逃离 Grafana 生态,Alloy 是 OTel Collector 的 superset;只有要用其他后端(Datadog、Honeycomb)才用原生 OTel Collector。
候选 3:商业化(Datadog / Honeycomb / Logz.io)
UI 最好,支持最全。钱的问题:
- Datadog 的日志定价是
$1.27/GB ingest + $2.50/M indexed events - 一个中等规模 agent 任务每步产生 5-10 条 info + debug 日志,20 步 × 100 任务/天 = 2 万条/天 = 6 万/月 = 小几百刀
- 开源方案同量级成本 < $10(对象存储 + 小 VM)
结论:钱宽裕的时候选。内部工具阶段没必要。
Zapvol 现有基建
pino 配置(apps/server/src/lib/logger.ts)
const pinoLogger = pino(
{ level: process.env.LOG_LEVEL || (isDev ? "debug" : "info") },
isDev ? pretty({ colorize: true, translateTime: "HH:MM:ss" }) : undefined,
);
开发态走 pino-pretty(彩色、好读),生产态输出 JSONL——一份到 stdout 供 docker logs,一份由 pino-loki
transport 直接推给 Loki,中间没有 collector 消费它。
event-first schema
log.info("task.created", { taskId, userId });
log.error("stream.failed", { taskId, err }, "Stream failed");
event 永远是第一个必需参数。 这是全系统的约定,贯穿 @zapvol/backend、@zapvol/server、@zapvol/desktop。后果:
- Grafana 里
event="task.created"就能精确筛选一类事件 - 所有事件名天然形成一个可审计的 event catalog
- 新开发者加日志时被迫想“这件事叫什么”——而不是写
log.info("something happened")
AsyncLocalStorage 注入
function mergeContext(event, data) {
const ctx = RequestContext.get();
if (ctx?.traceId) merged.traceId = ctx.traceId;
if (ctx?.userId) merged.userId = ctx.userId;
// ...
}
每条日志自带 traceId 和 userId,不用显式传。排查时用 traceId 就能拎出一次完整请求的所有日志——跨 service、跨 async
boundary。
已存在的关键 event(摘录)
| Event 名 | 发生位置 | 级别 | 业务含义 |
|---|---|---|---|
task.created / task.completed | routes/tasks.ts | info | 任务生命周期 |
stream.messages_prepared | agent-loop.ts | info | 一轮起手的 prefix 尺寸(input vs model messages) |
compaction.step_fired | agent-loop.ts | info | in-loop 压缩触发(仅 savedTokens > 0 时) |
compaction.budget_measured | agent-loop.ts | info | 实测 overhead(instructions + tools)vs 上下文窗口 |
agent.created | agent-loop.ts | info | ToolLoopAgent 装配完成 |
stream.step_usage | agent-loop.ts(createOnStepEnd) | debug | 每步 token usage——不上报 Loki |
cache.breakpoints_placed | model.ts(markPrefixCacheBoundary) | debug | cache 断点落点(placedAt)——不上报 Loki |
只有 info+ 会进 Loki(见上面的 pino 配置)。两个 debug 事件带着最丰富的 cache / per-step 细节,但按设计这些细节落在
admin 任务检查器(DB)里,聚合趋势则落在 OTel trace(Tempo,用 TraceQL)里,而不是 Loki。哪个问题归哪个面,见
可观测面板 → 三个面。做 Loki 面板时,把 info 级事件当 primary key。
三种部署形态
形态 A:Grafana Cloud 免费档(推荐起步)
最简单,也是 Zapvol 在跑的。开 Grafana Cloud 账号,不用额外部署任何东西——应用进程自己推两条信号。成本:$0/月(50GB logs + 50GB traces + 10K metrics)。
日志——设好 LOG_LOKI_*,pino 内置的 pino-loki transport(后台 worker 线程)直接推给 Loki:
LOG_LOKI_ENABLED=true
LOG_LOKI_URL=https://logs-prod-XX.grafana.net
LOG_LOKI_USER=<loki-instance-id>
LOG_LOKI_TOKEN=<grafana-cloud-token>
Trace——设好 OTEL_*,OTLP exporter(BatchSpanProcessor,后台定时器)直接推给 Tempo。仅
server,desktop 不注册 exporter:
OTEL_TRACES_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-<zone>.grafana.net/otlp
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <base64(tempo-instance-id:token)>
Loki 凭据和 OTLP/Tempo 凭据是两套不同的 instance-id/token,别互相复用。没有 sidecar、没有
config.alloy:两个开关默认关、变量没设时是 no-op,所以同一个镜像本地和生产都能跑。
形态 B:自建单机
单台 VM 跑 Loki + Grafana + Alloy。数据留本地对象存储(S3 / Cloudflare R2)。
- 成本:VM + 对象存储 ≈ $10-30/月(取决于日志量)
- 运维:需要自己管 Loki 存储 retention、Grafana 升级
- 适合:已经有现成 VM 基建、不想把日志往外送的团队
形态 C:Kubernetes
Alloy 作为 DaemonSet,每个节点一个。Loki 以 StatefulSet 跑在集群内,或用托管 Loki。
- 成本:取决于集群规模
- 运维:标准 K8s 运维模式
- 适合:已经跑 K8s 的团队
起步强烈建议形态 A——零基建负担,用不爽再迁。Loki 数据可以通过 Grafana 的 loki-migrate 工具搬走。
以上三种是生产部署形态。本地 dev 不上报 Loki——按设计,dev 只走进程内 pino-pretty 终端输出(把日志推给 Loki
的 worker-thread transport 会和 --inspect 调试端口相冲,所以 Loki 上报仅在生产启用)。开发时要观察 cache / 压缩行为,用系统内置的 admin 运行时上下文检查器,而不是 Grafana。
Label Cardinality 陷阱(必读)
Loki 的存储成本几乎全部由 label 组合数(cardinality) 决定,不是日志量。规则:
| 字段 | 能否作为 label | 原因 |
|---|---|---|
event | 可以 | 有限枚举(几十种) |
module | 可以 | 有限枚举 |
level | 可以 | 5 种 |
taskId | 不可 | 高基数(每任务一个,几百万级) |
userId | 不可 | 中高基数 |
traceId | 不可 | 每请求一个 |
| 任何数值字段(tokens、ratio 等) | 不可 | 连续值 |
一条错误配置能让 Loki 慢 100×、存储涨 50×。规则:
- Label = “我会用它做
sum by分组的”;字段 = “我会用它做精确查询的” - 禁止把 ID 类字段设为 label
- 新 label 上线前先在预发环境跑一周,监控
loki_ingester_memory_streams
查询时用 | json 解析字段,和 label 的区别:
# label 过滤(快)
{event="stream.step_finished"}
# 字段过滤(慢,但不贡献 cardinality)
{event="stream.step_finished"} | json | taskId="abc-123"
两者能组合使用。正确做法:用 label 粗过滤到几百万条内,再用字段精筛。
什么时候该升级到 Prometheus 指标
日志适合查询具体某次行为(“这个任务为什么 cache miss”),指标适合长期趋势+告警(“过去 7 天 cache hit ratio 95 分位”)。
升级信号:
| 信号 | 动作 |
|---|---|
| 某个 Dashboard 查询经常超过 30 秒 | 把那个指标用 prom-client emit,从 Prometheus 查 |
| 需要声明式告警规则(“ratio < 0.3 持续 5 分钟”) | Prometheus Alertmanager |
| 日志量接近 Grafana Cloud 免费档上限 | 把 debug 级 event 降级为 metrics、只保留 info+ 在 Loki |
升级路径:应用里加 prom-client、暴露一个 /metrics 端点(或 remote_write)。今天不需要 collector——Prometheus 或
Grafana Cloud 的 metrics 端点可以直接抓应用;将来若跑了 Alloy,它的 prometheus.scrape /
prometheus.remote_write 可以在前面接一层。
用 @ai-sdk/otel 做分布式追踪(已上线)
日志回答的是“这件具体的事为什么发生”,它不带时延。一次 agent run 横跨 task-orchestrator.ts、engine 的 20+ step
循环、穿 sandbox 的 tool 调用、穿 WebSocket 的 BUA session——而“哪一步卡了、卡了多久”这种问题,日志只能靠人肉按时间戳排序来拼。分布式追踪直接回答它,而且现在已在 server 上线。
@ai-sdk/otel 按 OpenTelemetry GenAI semantic conventions emit span——无需自定义埋点:
| 产出 | 名称 |
|---|---|
| Span | invoke_agent {modelId} · chat {modelId} · execute_tool {toolName} |
| Usage 属性 | gen_ai.usage.input_tokens · output_tokens · cache_read.input_tokens · cache_creation.input_tokens |
| 请求/响应属性 | gen_ai.request.model · request.temperature · response.finish_reasons · response.id |
| 时延(span 属性) | gen_ai.client.operation.duration · ...time_to_first_chunk(TTFO) · ...time_per_output_chunk · gen_ai.execute_tool.duration |
产出的是 span,不是 metrics。@ai-sdk/otel 不注册 meter——时延数字作为 span 属性活在 Tempo 里,用 TraceQL
查,不是 Prometheus 时序。它解锁的面板见
可观测面板。性能统计也能通过
onLanguageModelCallEnd({ usage, performance }) 回调命令式拿到:response time、total step time、tool execution
time、time to first output、output tokens per second。
一次注册,全进程点亮
不必在每个调用点开开关。启动时注册一次,这个进程里所有模型调用就都带上了追踪——而且 server 和 BullMQ worker 两个进程各注册一次,因为真正跑 agent 的是 worker,HTTP 请求只负责把任务塞进队列:
// apps/server/src/lib/otel.ts —— index.ts 和 worker.ts 里都最先调用
registerTelemetry(new OpenTelemetry());
关键在 registerTelemetry 把集成挂到 globalThis 上:于是进程里每个 generateText / streamText /
ToolLoopAgent 默认就发 span,不必再逐调用打 isEnabled——那是 AI SDK 5/6 的 experimental_telemetry 老形态,SDK 7
换成 telemetry 选项,注册即默认开。每个调用仍带一个 telemetry 标签(走共享的 aiTelemetry(functionId)),让 span
按 functionId 归到一组:agent.main、agent.subagent、title、compaction。而 @zapvol/backend
对遥测一无所知——没人注册时标签就是空转,所以从不注册的 desktop 一条 span 都不会产生。
prompt 正文默认不记——需要时怎么打开
这里藏着一个危险的默认值:AI SDK 把 recordInputs / recordOutputs 默认设成开,OpenTelemetry 集成又没有全局开关能一把关掉——放着不管,每个
span 都会驮上完整的 system prompt、用户消息、模型输出。所以 aiTelemetry() 索性在每个调用都把两者按死成 false:span
只留骨架和度量——步、耗时、token usage、finish reason、错误,绝不带对话正文。
真有 bug 需要看 prompt/输出正文时,对单个进程、临时打开:
OTEL_CAPTURE_CONTENT=true # 该进程的 span 开始带 gen_ai.input.messages / output.messages
查完改回去。开着时要认三笔代价:
- 敏感数据外流:BUA 驱动员工已登录的会话,prompt 可能含你不想进 Tempo 的私密数据。
- ingest 成本:正文动辄每 span 几十 KB,免费档 trace 配额很快耗尽。
- 内存:大 span 会在
BatchSpanProcessor队列里待到下次 flush,把大字符串钉住。
要长期看 prompt/输出正文,用 admin 任务检查器(DB)——那是为此建的产品面——不是 trace 后端。
一个 trace id 贯穿 logs 和 traces
在 Tempo 看到一条慢 trace,下一秒你想看的一定是它当时打了什么日志。可这一跳能不能成,全卡在一件事上:两边的 id 必须是同一个值、同一种格式。两处改动把它焊死:
RequestContext.newTraceId()现在返回 32 位 hex 字符串(原来是 UUID)——即便没有活跃 span,也是合法的 Tempo / W3C trace id。- 后台 agent-run 入口(
worker.ts的 job、schedule/nudge fire、browser entrypoint)跑在runWithTrace()里,它起一个 OTel span,并把RequestContext.traceId设成spanContext().traceId。
logger 的 context merge 一字未动,照样把 ctx.traceId 打进每行,只是这个值现在是真的 OTel id。于是一个 job 的 Loki
日志和它的 Tempo span 共享一个 id:从慢 span 上复制 trace_id,在 Loki 跑
{app="zapvol-server"} | json | traceId="<那个 id>",就拿到整条 run 的日志。HTTP 请求 seam 故意不起 span(大多只是
enqueue),它们仍拿到一个 32 位 hex 的 traceId 用于日志归因。
接到 Grafana Cloud(直连)
Grafana Cloud 有原生 OTLP gateway(https://otlp-gateway-<zone>.grafana.net/otlp,basic auth =
base64(instanceID:token)),把 traces 路由到 Tempo。exporter 直接指向它,没有 collector,和形态 A、以及 pino-loki
日志直连路径一致:
OTEL_TRACES_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-<zone>.grafana.net/otlp
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <base64(instanceID:token)>
开关沿用 LOG_LOKI_* 的门控:OTEL_TRACES_ENABLED=true 且 endpoint 存在,否则 warn + no-op,绝不因 env 配错而抛异常。Tempo
凭据与 Loki 凭据是不同的 instance-id/token。
collector 何时才值得引入(Alloy)
直连是当前答案,因为导出是异步、离请求路径的,所以 collector 买不到任何延迟收益。collector——Alloy,一个 OTel Collector 的 superset——只有在出现具体信号时才值那个额外容器:
| 信号 | 只有 collector 能给的 |
|---|---|
| Tempo 50GB trace 配额被“20 步 × N 任务”压满 | tail sampling:看完整条 trace 的所有 span 之后再决定留不留(留出错 / 慢的)。直连 exporter 只能 head sample,对结果无感。 |
| Grafana Cloud 抖动开始丢遥测 | 缓冲 + 重试与应用解耦:应用只对 localhost 说话,collector 扛住积压。 |
| logs + traces + metrics + 主机指标想要单一出口 | 一个出口、一套凭据、集中批处理。 |
在这些信号咬人之前,那个额外进程纯是运维负担。真到了那天,日志也迁到同一个 Alloy 上以保持对称。
本地如何调试
上面都是生产上报。要在本地拿自己的 Grafana Cloud 跑通——追踪在 dev 也能跑,因为 BatchSpanProcessor
在主线程,不像 Loki 那条 worker-thread transport 会和 --inspect 相冲:
-
把三个
OTEL_*变量放进apps/server/.env(header 由instanceID:token的base64拼出)。 -
先 smoke 通路:不用 agent、不用 Redis / DB:
pnpm --filter @zapvol/server exec tsx scripts/smoke-otel.ts它 emit 一个 span 并 flush;~15 秒内在 Grafana → Explore(Tempo)按
service.name="zapvol-smoke"查到。出现OTEL_TRACES_ENABLED=true but … missing说明 env 没加载。 -
端到端:起 server + worker(agent run 在 worker 里发生),触发一个任务,再在 Tempo 按
service.name="zapvol-worker"查,能看到job:task.run → invoke_agent → chat/execute_tool这棵树。把它的trace_id填进上面那条 Loki 查询,确认日志和 trace 共享一个 id。
开发时要观察 cache / 压缩行为,内置的 admin 运行时上下文检查器仍是对的面——那份细节在 DB 里,不在 trace 里。
相关章节
- Node 运行时健康 — 侦测 Node 是否在劣化(内存/GC/事件循环/句柄)+ 各方案性能开销
- 可观测面板 — 当前内置的 4 个核心 Dashboard 和判读标准
- Context Compaction — 压缩边界如何影响 cache 断点设计(
cache.breakpoints_placed事件的由来) - prepareStep 语义 —
stream.step_finished事件产生的位置