可观测栈总览

日志由 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 才值得引入、陷阱在哪。不是部署手册,是运维这个系统需要的心智模型

管道全景

可观测性管道 pino → Alloy → Loki → Grafana —— 四层可替换 应用层 (Application Layer) @zapvol/server · @zapvol/backend · @zapvol/desktop pino —— 结构化 JSON,event-first schema AsyncLocalStorage 自动注入 traceId 与 userId stdout → JSONL 采集层 (Collection Layer) Grafana Alloy(OpenTelemetry Collector 血统) 读 stdout · 提取 JSON 字段 · 筛选 label River 配置;:12345 端口有可视化调试 UI 批量 + gzip 推送 存储层 (Storage Layer) Grafana Loki label 索引(低基数)+ 原文 chunks 存于对象存储 成本跟 label cardinality 挂钩,不跟日志总量 LogQL 查询 可视化层 (Visualization Layer) Grafana —— 仪表盘 · 声明式告警 Dashboard JSON 提交到 ops/grafana/dashboards/ 告警规则以 YAML 形式提交到 ops/grafana/alerts/

今天没有 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;
  // ...
}

每条日志自带 traceIduserId,不用显式传。排查时用 traceId 就能拎出一次完整请求的所有日志——跨 service、跨 async boundary。

已存在的关键 event(摘录)

Event 名发生位置级别业务含义
task.created / task.completedroutes/tasks.tsinfo任务生命周期
stream.messages_preparedagent-loop.tsinfo一轮起手的 prefix 尺寸(input vs model messages)
compaction.step_firedagent-loop.tsinfoin-loop 压缩触发(仅 savedTokens > 0 时)
compaction.budget_measuredagent-loop.tsinfo实测 overhead(instructions + tools)vs 上下文窗口
agent.createdagent-loop.tsinfoToolLoopAgent 装配完成
stream.step_usageagent-loop.tscreateOnStepEnddebug每步 token usage——不上报 Loki
cache.breakpoints_placedmodel.tsmarkPrefixCacheBoundarydebugcache 断点落点(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×。规则:

  1. Label = “我会用它做 sum by 分组的”;字段 = “我会用它做精确查询的”
  2. 禁止把 ID 类字段设为 label
  3. 新 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——无需自定义埋点:

产出名称
Spaninvoke_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.mainagent.subagenttitlecompaction。而 @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 相冲:

  1. 把三个 OTEL_* 变量放进 apps/server/.env(header 由 instanceID:tokenbase64 拼出)。

  2. 先 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 没加载。

  3. 端到端:起 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 事件产生的位置
这页有帮助吗?