本地 dev 接入 Grafana Cloud
开发机通过 pino-loki transport opt-in 把日志直推 Grafana Cloud —— 10 分钟设置,不依赖 Docker
为什么要在本地就接
Dev 写代码的同时希望自己的日志直接流进 Grafana Cloud,为了边开发边观察 cache hit ratio、压缩触发频率、断点落位分布——不是等部署到生产才第一次看见这些指标。在改 cache 相关代码的 PR 里尤其重要:可以在本机跑对照实验,立刻看到面板数据变化,而不是靠”应该正常吧”的主观判断。
约束:
- 不能强制配置——没在用 Grafana Cloud 的 dev 不应该被拖住,env vars 未设时完全不启用
- 不能拖慢本地反馈循环——终端 pino-pretty 的彩色输出必须保留,网络失败不能让
pnpm dev:server卡住 - 不能依赖 Docker——dev 机不一定常开 Docker,不能要求再跑一个 Alloy 容器
两条路径的选择
| 路径 | 启动成本 | 生产同构度 | 推荐场景 |
|---|---|---|---|
| pino-loki transport 直推(推荐) | 10 分钟 | 中(跳过了 Alloy 这一层) | 90% 日常 dev |
| 本地 Alloy 容器 | 30 分钟 + Docker | 高(跟生产完全同栈) | 调试 Alloy 配置 / 验证 label 提取规则 |
默认选 pino-loki 直推。生产同构度的差异集中在 Alloy 的 label 提取、batching、retry 策略等运维关注点——这些不是本地 dev 的关注点,本地关注的是”我的日志事件能不能在面板上看到”。Alloy 留给 staging / CI 环境做 production-parity 测试。
pino-loki 直推方案
整体形态
pino (app)
├── transport 1: pino-pretty → stdout (终端肉眼看)
└── transport 2: pino-loki → Loki HTTP (Grafana Cloud 面板)
opt-in 启用(env var 控制)
两个 transport 并行工作,互不干扰。pino 内部的 worker_threads 保证 HTTP 推送不阻塞主 event loop。
Step 1:开 Grafana Cloud 账号
grafana.com/auth/sign-up 免费档即可:每月 50 GB logs + 10K metrics + 50 GB traces。
进入 Stack 管理页面,记下三项信息:
- Loki endpoint:
https://logs-prod-XX.grafana.net(XX 随所选 region 变化) - User / instance ID:一串数字
- API token:Security → API Tokens → 新建,scope 选
logs:write
Step 2:本地 env 配置
在 apps/server/.env.local 中加入(不要 commit——仓库 .gitignore 已默认排除 .env.local):
# 开关:true 启用 Loki 推送,false 或未设置时不启用。
# 设计为独立开关是为了能在不清空 URL/TOKEN 的情况下临时关闭上报
# (比如跑敏感调试、省免费档配额、离线工作)。
LOG_LOKI_ENABLED=true
# 开关为 true 时必填;开关为 false 时这些值会被忽略
LOG_LOKI_URL=https://logs-prod-XX.grafana.net
LOG_LOKI_USER=123456
LOG_LOKI_TOKEN=glc_eyJvIjoi...
# 多 dev 隔离:日志流上带 dev_user label 便于面板筛选
LOG_LOKI_LABELS_EXTRA=dev_user=jonathan
三档开关语义:
LOG_LOKI_ENABLED | URL/TOKEN 是否齐全 | 行为 |
|---|---|---|
true | 是 | 推送启用,正常工作 |
true | 否 | 启动时 console.warn 提示缺凭据,本次进程不推送 |
false / 未设置 | 随意 | 不读 URL/TOKEN,静默跳过 |
日常工作流:.env.local 里把 URL/TOKEN 配置一次放着,需要上报时 LOG_LOKI_ENABLED=true,不需要时改成
false——不用清空凭据。
dev_user label 的用途:团队里多个 dev 同时启用时,面板查询可以用 {dev_user="jonathan"}
只看自己的日志,避免互相干扰。值必须是低基数的(见Label Cardinality 陷阱),用固定 username 或 initials,不要填动态内容。
Step 3:装依赖
pnpm --filter @zapvol/server add pino-loki
pino-loki 是 pino 的 Loki transport,基于 worker thread 做非阻塞 HTTP 推送。
Step 4:logger 改造为多 transport
apps/server/src/lib/logger.ts 的核心改造(~20 行 diff):
import pino from "pino";
const isDev = process.env.NODE_ENV !== "production";
// 两档判断:必须 LOG_LOKI_ENABLED=true 且凭据齐全才推送
const lokiRequested = process.env.LOG_LOKI_ENABLED === "true";
const lokiReady = Boolean(process.env.LOG_LOKI_URL && process.env.LOG_LOKI_USER && process.env.LOG_LOKI_TOKEN);
const lokiEnabled = lokiRequested && lokiReady;
if (lokiRequested && !lokiReady) {
// 意图启用但凭据缺失 —— 显式告警,本次进程不推送(不 throw,避免生产启动被块)
// eslint-disable-next-line no-console
console.warn(
"[logger] LOG_LOKI_ENABLED=true but LOG_LOKI_URL/USER/TOKEN incomplete; Loki shipping disabled for this process",
);
}
const targets: pino.TransportTargetOptions[] = [
// 本地终端 —— dev 彩色 pretty / prod 纯 JSON
isDev
? {
target: "pino-pretty",
level: "debug",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
}
: { target: "pino/file", level: "info", options: { destination: 1 } }, // stdout
];
if (lokiEnabled) {
targets.push({
target: "pino-loki",
level: "info", // 只推 info+,省免费档配额(见下方"配额管理")
options: {
host: process.env.LOG_LOKI_URL,
basicAuth: {
username: process.env.LOG_LOKI_USER,
password: process.env.LOG_LOKI_TOKEN,
},
labels: {
app: "zapvol-server",
env: isDev ? "dev" : "prod",
...parseExtraLabels(process.env.LOG_LOKI_LABELS_EXTRA),
},
batching: true,
interval: 5, // 5s 批量推送,省 API 调用
},
});
}
const pinoLogger = pino({ level: process.env.LOG_LEVEL || (isDev ? "debug" : "info") }, pino.transport({ targets }));
function parseExtraLabels(raw: string | undefined): Record<string, string> {
if (!raw) return {};
return Object.fromEntries(raw.split(",").map((kv) => kv.split("=").map((s) => s.trim())));
}
下方的 wrapLogger / createLogger 逻辑完全不变——这次改造是纯增强,不破坏任何现有 API。
Step 5:验证
pnpm dev:server
发几个请求触发日志。打开 Grafana Cloud → Explore → 选 Loki datasource → 查:
{app="zapvol-server", env="dev", dev_user="jonathan"}
5 秒内应该能看到日志流进来(interval: 5 是 batch 周期)。如果超过 30 秒还没看到:
- 查
pnpm dev:server终端是否有pino-lokitransport error(通常是 token / URL 错误) - 查 Grafana Cloud 控制台是否有 ingest 错误记录
- 确认
.env.local被加载了(Astro / Vite 可能需要重启)
Label cardinality 在本地的规则
跟生产同样的规则(完整背景见主文档):
| 字段 | 能做 label 吗 | 备注 |
|---|---|---|
app | 是 | 固定”zapvol-server” |
env | 是 | ”dev” / “prod” 两值 |
dev_user | 是 | 团队人数,低基数 |
event | 是 | 在 Alloy 的 pipeline 阶段从 JSON 提取;本地 pino-loki 暂不做,查询时用字段过滤 |
taskId | 不可 | 高基数(每任务一个) |
userId | 不可 | 中高基数 |
traceId | 不可 | 每请求一个 |
| 任何数值字段 | 不可 | 连续值 |
pino-loki 默认把 labels 配置里列的所有项作为 Loki label。绝对不要把 traceId 或 taskId 放进 labels
—— 会在第一天就烧掉免费档配额并让 Loki 性能崩溃。
配额管理
免费档 50 GB logs / 月。实测估算:
| 场景 | 日志量 |
|---|---|
| 一次典型 agent 任务(20 step) info+ 日志 | ~150-300 KB |
| 单 dev 一天 200 次任务 | ~60 MB |
| 单 dev 一整月活跃开发 | ~1.8 GB |
| 整个团队 10 dev 同时开启 | ~18 GB/月 |
单 dev 远远用不完。但:
LOG_LEVEL=debug本地跑一整天可能单日 1-2 GB——cache.breakpoints_placed这种每 step 触发的 debug 事件量大- 整个团队都设
LOG_LEVEL=debug同时开启推送,免费档会顶掉
保护策略:
- pino-loki transport 的 level 固定
info——debug只进终端不进 Loki。代码里已经是这样。 - 终端
LOG_LEVEL=debug按需临时开——比如调试一个特定的 cache miss 时,而不是作为默认 - Grafana Cloud 控制台设 billing 预警,提前告警配额消耗
密钥保护
.env.local 必须在 .gitignore——仓库默认已处理,不需额外配置。
额外建议:加一条 pre-commit 钩子扫 glc_ey 前缀字符串(Grafana Cloud API
token 的固定特征)防止意外 commit。简化版 shell 示意:
# .git/hooks/pre-commit
if git diff --cached | grep -qE 'glc_ey[A-Za-z0-9_-]{20,}'; then
echo "ERROR: Grafana Cloud token detected in staged changes. Abort."
exit 1
fi
网络失败降级
pino-loki 有内置 retry。dev 机短暂断网时:
pnpm dev:server照常运行——HTTP 推送失败异步 fail,不阻塞主流程- 终端 pino-pretty 照常可见——它跟 pino-loki 完全独立
- Grafana Cloud 那端不会补推——断网期间的日志本地这段丢失
如果需要”网络失败时仍能事后补推”,升级到本地 Alloy 方案(Alloy 有本地 WAL,可断网缓存)。日常 dev 很少有这需求。
什么时候升级到本地 Alloy
直推够用,只有以下极少数场景值得起 Alloy 容器:
| 场景 | 为什么 Alloy 更合适 |
|---|---|
调试生产用的 config.alloy 配置 | 本地能反复试 label 提取规则、pipeline 阶段 |
| 同时采集 Docker 里其他服务的 stdout | 单 Alloy 容器统采多服务 |
| 验证 label cardinality 真实影响 | Alloy 的 pipeline_stages 能 mock 生产过滤逻辑 |
| 需要本地 WAL 缓存应对断网 | 直推没有这个 |
升级路径:
- 在
.env.local里注释掉LOG_LOKI_URL(pino 回到单 stdout 输出) - 起一个 Alloy 容器按主文档里的
config.alloy配置 - 跟生产配置用同一份
config.alloy——本地调试后直接 promote