本地 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 endpointhttps://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_ENABLEDURL/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 秒还没看到:

  1. pnpm dev:server 终端是否有 pino-loki transport error(通常是 token / URL 错误)
  2. 查 Grafana Cloud 控制台是否有 ingest 错误记录
  3. 确认 .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。绝对不要traceIdtaskId 放进 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 同时开启推送,免费档会顶掉

保护策略:

  1. pino-loki transport 的 level 固定 info——debug 只进终端不进 Loki。代码里已经是这样。
  2. 终端 LOG_LEVEL=debug 按需临时开——比如调试一个特定的 cache miss 时,而不是作为默认
  3. 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 缓存应对断网直推没有这个

升级路径:

  1. .env.local 里注释掉 LOG_LOKI_URL(pino 回到单 stdout 输出)
  2. 起一个 Alloy 容器按主文档里的 config.alloy 配置
  3. 跟生产配置用同一份 config.alloy——本地调试后直接 promote

相关章节

这页有帮助吗?