100、日志与可观测性

要点

  • 传统服务器有 /var/log,有 pm2 /systemd /docker logs,出问题 SSH 上去看日志
  • 在 wrangler.jsonc 里打开这一项,Cloudflare 就会自动把日志存进 Dashboard
  • console.log('error happened') 这种日志字符串在 Dashboard 里很难过滤
  • 日志好用,但它不告诉你「错误的聚合情况」

内容

1. Workers 的可观测性为什么「不一样」

传统服务器有 /var/log,有 pm2 /systemd /docker logs,出问题 SSH 上去看日志。Workers 没有这些——代码跑在全球几百个节点上,一次请求可能落到东京、法兰克福、圣保罗里的任意一个。

这带来两个现实:

  1. 你看不见单个节点。你要的是「全球汇总的视角」
  2. 日志不会自己持久化。Worker 跑完了,console.log 就飘走了,除非你主动收集

不过 Cloudflare 在这方面的工具已经比较全了,目前可用的:

工具用途
wrangler tail实时在终端里看日志,开发期最常用
Workers Observability(新功能)Dashboard 上自动存日志、trace、metrics
Logpush把日志推送到 R2/S3/第三方(Datadog、Axiom 等)
Analytics Engine超便宜的自定义指标写入
Tail Workers用一个 Worker 处理另一个 Worker 的日志(过滤、转发)
Sentry / 第三方 SDK业务级错误追踪

这一篇把这些工具过一遍,最后给 AI 网关补上完整的可观测性。

2. wrangler tail:开发期的救命稻草

最直接的调试方式:

// terminal
# 跟踪生产 Worker 的实时日志
 
npx wrangler tail
 
# 跟踪特定 Worker
 
npx wrangler tail my-api-gateway
 
# 只看错误
 
npx wrangler tail --status=error
 
# 只看某个 IP 的请求
 
npx wrangler tail --ip-address=203.0.113.42

终端里会看到每次请求的 method/path/status、以及你在代码里 console.log 的东西。生产环境临时查问题,这个命令足以解决 80% 场景。

它的两个局限:

  • 必须开着终端:关了就丢
  • 历史不可回查:你看到的是「从这一刻起」的日志

要长期可见 + 可回查的日志,需要下一节的 Workers Observability。

3. Workers Observability:Cloudflare 自己的日志平台

wrangler.jsonc 里打开这一项,Cloudflare 就会自动把日志存进 Dashboard:

// wrangler.jsonc
{
 
  "observability": {
 
    "logs": {
 
      "enabled": true,
 
      "head_sampling_rate": 1  // 采样率 1 = 全部记录
 
    }
 
  }
 
}

启用后:

  • Cloudflare Dashboard → Workers → 你的 Worker → Logs 可以看到完整历史
  • 默认保留 7 天(付费版可延长)
  • 支持按 status code / duration / path / 自定义字段过滤
  • Query Builder 可以像 SQL 一样查日志

中小项目用这个基本够了,不一定需要接第三方日志平台。

3.1 采样率怎么选

head_sampling_rate 是采样率,0~1 的浮点数,代表记录多大比例的请求。比如 0.1 就是只记录 10% 的请求——流量大了之后全量记录太贵,只采样一部分就够看趋势了:

  • 1:全量(开发环境、刚上线)
  • 0.1:10%(流量大后的默认推荐)
  • 0.01:1%(超大流量、只看趋势)

错误日志不受采样影响——Workers Observability 会额外自动记录所有 5xx,你不会因为采样而漏掉错误。

4. 结构化日志:console.log 怎么写

console.log('error happened') 这种日志字符串在 Dashboard 里很难过滤。改成 JSON 对象:

// src/lib/logger.ts
export const log = {
 
  info: (event: string, data?: Record<string, unknown>) => {
 
    console.log(JSON.stringify({ level: 'info', event, ...data, ts: Date.now() }))
 
  },
 
  warn: (event: string, data?: Record<string, unknown>) => {
 
    console.warn(JSON.stringify({ level: 'warn', event, ...data, ts: Date.now() }))
 
  },
 
  error: (event: string, data?: Record<string, unknown>) => {
 
    console.error(JSON.stringify({ level: 'error', event, ...data, ts: Date.now() }))
 
  },
 
}

Workers Observability 会自动识别 JSON 结构,你在 Query Builder 里可以直接按字段过滤:

// src/routes/chat.ts
import { log } from '../lib/logger'
 
app.post('/chat', async (c) => {
 
  const body = await c.req.json()
 
  const startedAt = Date.now()
 
  try {
 
    const result = await callLLM(body)
 
    log.info('llm.success', {
 
      model: body.model,
 
      userId: c.get('apiKeyId'),
 
      tokens: result.usage.total_tokens,
 
      duration: Date.now() - startedAt,
 
    })
 
    return c.json(result)
 
  } catch (err) {
 
    log.error('llm.failed', {
 
      model: body.model,
 
      userId: c.get('apiKeyId'),
 
      message: (err as Error).message,
 
      duration: Date.now() - startedAt,
 
    })
 
    throw err
 
  }
 
})

之后 Dashboard 里可以查「最近 1 小时内 model=claude-opus-4-6 的所有失败请求」——这种查询在字符串日志里是做不到的。

4.1 一个好日志的字段清单

生产 Worker 建议每次请求都带上这几个字段:

字段含义
event业务事件名(如 llm.success、queue.retry)
levelinfo / warn / error
userId / apiKeyId哪个用户
requestId请求级唯一 ID(方便跨日志串起来)
duration耗时(毫秒)
model / resource这次操作的对象
error.message / error.stack出错时的详细信息

requestId 可以用 crypto.randomUUID() 在请求入口生成,通过 c.set('requestId', ...) 在整个请求里共享。

5. 错误追踪:Sentry 之类

日志好用,但它不告诉你「错误的聚合情况」。业务级错误追踪(Sentry、Bugsnag、Axiom)解决的是这类问题:

  • 同一个错误在过去 1 小时发生了多少次?
  • 哪个版本部署后开始的?
  • 影响了多少独立用户?
  • 异常栈是什么?

5.1 给 Hono 接 Sentry

Sentry 官方提供了 Workers SDK:

// terminal
npm install @sentry/cloudflare
// src/index.ts
import * as Sentry from '@sentry/cloudflare'
 
import { Hono } from 'hono'
 
type Bindings = {
 
  SENTRY_DSN: string
 
}
 
const app = new Hono<{ Bindings: Bindings }>()
 
app.onError((err, c) => {
 
  // 让 Sentry 抓到
 
  Sentry.captureException(err, {
 
    tags: { path: c.req.path, method: c.req.method },
 
    user: { id: c.get('userId') },
 
  })
 
  return c.json({ error: 'Internal Server Error' }, 500)
 
})
 
export default Sentry.withSentry(
 
  (env: Bindings) => ({
 
    dsn: env.SENTRY_DSN,
 
    tracesSampleRate: 0.1,
 
  }),
 
  app
 
)

Sentry.withSentry() 包一层默认导出,它会自动捕获未处理的异常、附加 Workers 特有的运行环境信息。

5.2 不要把所有东西都扔给 Sentry

一个常见反模式:console.error 全部 captureException 一遍。Sentry 按事件数计费,几小时内你就会收到账单告警。

原则:Sentry 只收需要人类介入修复的异常。业务级失败(校验错误、用户权限不足)走普通日志就行。

6. Analytics Engine:便宜到奢侈的自定义指标

Cloudflare 的 Analytics Engine 是专门给 Workers 写指标的时序数据库。免费版每天 10 万次写入 / 1 万次读取,个人项目够用。

适用场景:

  • 每次 LLM 调用的 token 数、延迟、成本
  • 每次缓存命中/miss 的统计
  • 每类业务事件的 counter

6.1 写入

// wrangler.jsonc
{
 
  "analytics_engine_datasets": [
 
    { "binding": "ANALYTICS", "dataset": "ai_gateway_metrics" }
 
  ]
 
}
// src/routes/chat.ts
type Bindings = { ANALYTICS: AnalyticsEngineDataset }  // Workers 内置类型
 
app.post('/chat', async (c) => {
 
  const result = await callLLM(body)
 
  // writeDataPoint 的字段分三类:
 
  // blobs — 字符串列(最多 20 个),用来过滤和 group by
 
  // doubles — 数值列(最多 20 个),用来求和、求平均
 
  // indexes — 采样索引(最多 1 个),高基数场景下的采样键
 
  c.env.ANALYTICS.writeDataPoint({
 
    blobs: [body.model, c.get('apiKeyId'), 'success'],
 
    doubles: [result.usage.total_tokens, Date.now() - startedAt],
 
    indexes: [c.get('apiKeyId')],
 
  })
 
  return c.json(result)
 
})

6.2 查询

Dashboard 里有 UI,或者用 SQL API:

// query.sql
SELECT
 
  blob1 AS model,
 
  SUM(_sample_interval) AS total_requests,
 
  SUM(double1) AS total_tokens,
 
  AVG(double2) AS avg_duration_ms
 
FROM ai_gateway_metrics
 
WHERE timestamp > NOW() - INTERVAL '1' HOUR
 
GROUP BY blob1
 
ORDER BY total_requests DESC

对 AI 项目来说 Analytics Engine 很实用:一行代码埋点,一行 SQL 出报表。比自己在 KV 里攒数据再 cron 汇总简单多了。

7. Logpush:把日志送到外部平台

如果你团队已经有 Datadog、Axiom、Grafana Loki 之类的统一日志平台,可以用 Logpush 把 Workers 日志直接推过去:

// terminal
npx wrangler logpush create \
 
  --dataset workers_trace_events \
 
  --destination "https://your-endpoint/path?token=xxx"

Logpush 支持的目的地:R2、S3、HTTP endpoint、Datadog、New Relic、Sumo Logic 等。配置好之后日志会被批量推送(通常每几分钟一批),而不是实时。

对绝大多数中小项目,Workers Observability 本身就够了,Logpush 主要给已经用了 Datadog 的大团队

8. 实战:AI 网关的完整可观测性

回到第 18 篇的 AI 网关,我们给它补全可观测性:

// src/index.ts
import { Hono } from 'hono'
 
import * as Sentry from '@sentry/cloudflare'
 
import { log } from './lib/logger'
 
type Bindings = {
 
  SENTRY_DSN: string
 
  ANALYTICS: AnalyticsEngineDataset
 
  OPENAI_API_KEY: string
 
}
 
const app = new Hono<{ Bindings: Bindings }>()
 
// 请求级 trace ID
 
app.use('*', async (c, next) => {
 
  const requestId = crypto.randomUUID()
 
  c.set('requestId', requestId)
 
  c.header('X-Request-Id', requestId)
 
  await next()
 
})
 
// 全局请求日志
 
app.use('*', async (c, next) => {
 
  const startedAt = Date.now()
 
  await next()
 
  log.info('http.request', {
 
    requestId: c.get('requestId'),
 
    method: c.req.method,
 
    path: c.req.path,
 
    status: c.res.status,
 
    duration: Date.now() - startedAt,
 
  })
 
})
 
app.post('/v1/chat/completions', async (c) => {
 
  const body = await c.req.json()
 
  const startedAt = Date.now()
 
  try {
 
    const result = await callLLM(body, c.env.OPENAI_API_KEY)
 
    // 结构化日志
 
    log.info('llm.success', {
 
      requestId: c.get('requestId'),
 
      model: body.model,
 
      userId: c.get('apiKeyId'),
 
      tokens: result.usage.total_tokens,
 
      duration: Date.now() - startedAt,
 
    })
 
    // Analytics Engine 埋点
 
    c.env.ANALYTICS.writeDataPoint({
 
      blobs: [body.model, c.get('apiKeyId'), 'success'],
 
      doubles: [result.usage.total_tokens, Date.now() - startedAt],
 
      indexes: [c.get('apiKeyId')],
 
    })
 
    return c.json(result)
 
  } catch (err) {
 
    log.error('llm.failed', {
 
      requestId: c.get('requestId'),
 
      model: body.model,
 
      userId: c.get('apiKeyId'),
 
      message: (err as Error).message,
 
    })
 
    // Analytics Engine 记录失败
 
    c.env.ANALYTICS.writeDataPoint({
 
      blobs: [body.model, c.get('apiKeyId'), 'error'],
 
      doubles: [0, Date.now() - startedAt],
 
      indexes: [c.get('apiKeyId')],
 
    })
 
    throw err  // 让全局 onError 接住 + Sentry 抓
 
  }
 
})
 
// 全局错误兜底
 
app.onError((err, c) => {
 
  Sentry.captureException(err, {
 
    tags: { path: c.req.path },
 
    extra: { requestId: c.get('requestId') },
 
  })
 
  return c.json({ error: 'Internal Server Error' }, 500)
 
})
 
export default Sentry.withSentry(
 
  (env: Bindings) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0.1 }),
 
  app as any
 
)

这套东西给你的可观测性:

  1. 每个请求都有 requestId(响应头里也返回),排查时可以串起所有日志
  2. 请求级日志(method/path/status/duration)自动化到中间件
  3. 业务级日志(llm.success / llm.failed)在关键分支手工写
  4. Analytics Engine 记录可查询的时序指标
  5. Sentry 捕获未处理异常,按聚合告警
  6. Workers Observability 在 Dashboard 里提供原生查询 UI

9. 小结

Workers 的日志默认不持久化——console.log 打完就丢了。加一行 observability.logs.enabled = true 就能在 Dashboard 里查历史日志。

根据团队规模选组合:

场景推荐组合
个人项目wrangler tail + Workers Observability
中小团队上面 + 结构化日志 + Analytics Engine
已上 Sentry 的团队上面 + Sentry SDK
已上 Datadog / Grafana 的团队上面 + Logpush 推送到现有平台

三个实用原则:日志要结构化(JSON 对象比纯字符串好查得多),每个请求带 requestId(方便串起跨服务日志),只把真正需要人修的异常扔给 Sentry(业务失败走普通日志)。