21.03-错误日志

要点

  • 错误日志要记录完整的错误信息:message、stack、cause
  • 区分业务错误和系统错误,前者走 warn,后者走 error
  • 全局 onError 兜底所有未捕获异常
  • Sentry 只收需要人类介入的异常,不要把校验错误也扔进去

内容

1. 错误日志的三个层次

错误日志不是简单的一句 console.error(err)。根据错误的性质,分三个层次:

层次含义日志级别示例
业务错误用户操作不符合预期warn参数校验失败、权限不足、余额不够
系统错误代码/基础设施异常error数据库连接失败、LLM API 超时
致命错误无法恢复的异常error + 告警OOM、未处理的 Promise rejection

不同层次的错误处理方式不同:

  • 业务错误:返回合适的 HTTP 状态码(400/401/403),记录 warn 日志,不需要告警
  • 系统错误:返回 500,记录 error 日志,需要监控和告警
  • 致命错误:记录 error 日志 + 触发告警 + 可能需要人工介入

2. 全局错误处理

Hono 的 onError 钩子捕获所有未处理的异常:

// src/index.ts
import { Hono } from 'hono'
import { log } from './lib/logger'
 
const app = new Hono()
 
app.onError((err, c) => {
  const requestId = c.get('requestId')
 
  log.error('unhandled.exception', {
    requestId,
    method: c.req.method,
    path: c.req.path,
    error: {
      message: err.message,
      stack: err.stack,
      cause: err.cause,
    },
  })
 
  return c.json(
    { error: 'Internal Server Error', requestId },
    500
  )
})

onError 是最后一道防线。正常情况下,业务代码应该自己捕获异常并处理;只有真正「没想到」的异常才会到这里。

2.1 不要把业务错误扔给 onError

一个常见错误模式:

// ❌ 错误做法:业务错误也用 throw
app.post('/v1/chat/completions', async (c) => {
  const body = await c.req.json()
 
  if (!body.messages) {
    throw new Error('messages is required')  // 这会触发 onError,记成 error 日志
  }
 
  // ...
})

这样做的问题是:

  1. onError 会把所有 throw 当成系统错误,记录 error 日志
  2. Sentry 会收到大量「messages is required」,淹没真正的异常
  3. 返回 500 而不是 400,语义不对

正确做法:

// ✅ 正确做法:业务错误直接返回
app.post('/v1/chat/completions', async (c) => {
  const body = await c.req.json()
 
  if (!body.messages) {
    log.warn('validation.failed', {
      requestId: c.get('requestId'),
      reason: 'messages is required',
    })
    return c.json({ error: 'messages is required' }, 400)
  }
 
  // ...
})

或者用自定义的错误类:

// src/lib/errors.ts
export class AppError extends Error {
  constructor(
    message: string,
    public statusCode: number = 500,
    public isOperational: boolean = true,
  ) {
    super(message)
  }
}
 
export class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400, true)
  }
}
// src/index.ts
app.onError((err, c) => {
  const requestId = c.get('requestId')
 
  if (err instanceof AppError && err.isOperational) {
    // 业务错误:warn 日志,不需要 Sentry
    log.warn('app.error', {
      requestId,
      message: err.message,
      statusCode: err.statusCode,
    })
  } else {
    // 系统错误:error 日志 + Sentry
    log.error('unhandled.exception', {
      requestId,
      error: {
        message: err.message,
        stack: err.stack,
      },
    })
 
    Sentry.captureException(err, {
      tags: { requestId, path: c.req.path },
    })
  }
 
  return c.json(
    { error: err.message || 'Internal Server Error', requestId },
    err.statusCode || 500
  )
})

3. 错误日志要记什么

一个完整的错误日志应该包含:

log.error('llm.failed', {
  requestId: c.get('requestId'),
  userId: c.get('userId'),
 
  // 错误本身的三要素
  error: {
    message: err.message,        // 人类可读的错误描述
    stack: err.stack,            // 调用栈(排查用)
    cause: err.cause,            // 原始错误(如果是包装过的错误)
    name: err.name,              // 错误类型(TypeError、Error 等)
  },
 
  // 业务上下文
  context: {
    model: body.model,           // 调用的模型
    tokens: body.max_tokens,     // 请求参数
    provider: 'openai',          // 第三方服务
  },
 
  // 环境信息(可选)
  environment: {
    nodeVersion: process.version,
    workerName: 'ai-gateway',
  },
})

3.1 error.stack 的价值

err.stack 是排查问题的关键。它告诉你错误发生在哪一行代码:

Error: Request failed with status code 429
    at callLLM (src/lib/llm.ts:45:11)
    at handler (src/routes/chat.ts:12:20)
    at dispatch (node_modules/hono/dist/compose.js:28:19)

没有 stack 的错误日志:「LLM 调用失败」——你只能猜是哪一步出了问题。

有 stack 的错误日志:「callLLM 函数在第 45 行抛出 429」——你立刻知道是 OpenAI 限流了。

3.2 error.cause 的价值

现代 JavaScript 支持 error.cause,用来保留原始错误链:

// src/lib/llm.ts
export async function callLLM(body: ChatRequest) {
  try {
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      body: JSON.stringify(body),
    })
 
    if (!response.ok) {
      throw new Error(`OpenAI API failed: ${response.status}`)
    }
 
    return await response.json()
  } catch (err) {
    // 包装成业务错误,保留原始错误
    throw new Error('LLM 调用失败', { cause: err })
  }
}

日志里记录 cause,就能追溯错误链:

log.error('llm.failed', {
  error: {
    message: err.message,        // "LLM 调用失败"
    cause: err.cause?.message,   // "OpenAI API failed: 429"
    stack: err.stack,
  },
})

4. 区分可重试和不可重试错误

不是所有错误都值得告警。区分:

错误类型是否可重试处理方式
网络超时自动重试(指数退避)
429 限流自动重试(退避到 rate limit 恢复)
401 认证失败检查 API key 配置
400 参数错误返回 400,让用户修改
500 服务端错误不确定可能重试一次,然后告警
// src/lib/llm.ts
export async function callLLM(body: ChatRequest, retries = 3): Promise<ChatResponse> {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      return await fetchLLM(body)
    } catch (err) {
      const isRetryable = err.status === 429 || err.status >= 500
 
      if (isRetryable && attempt < retries) {
        const delay = Math.pow(2, attempt) * 1000  // 指数退避:2s, 4s, 8s
        await new Promise(r => setTimeout(r, delay))
        continue
      }
 
      throw err
    }
  }
}

重试后的失败才值得记录 error 日志和触发告警。重试成功只需要 warn 日志:

log.warn('llm.retry', {
  requestId,
  attempt: 2,
  previousError: '429 Too Many Requests',
})

5. 错误日志的聚合和告警

单条错误日志的价值有限,聚合后才能发现问题:

  • 同一个错误在 1 小时内出现了 100 次?→ 需要立即排查
  • 某个用户的错误率突然升高?→ 可能是账号被盗或参数错误
  • 某个模型的错误率比昨天高 10%?→ 可能是第三方服务降级

这就是 Sentry 的价值——它自动聚合错误、统计频率、追踪影响用户数。

// 不要每条错误都 captureException
app.post('/v1/chat/completions', async (c) => {
  try {
    return await callLLM(body)
  } catch (err) {
    if (err.status === 429) {
      // 限流是常态,只记日志
      log.warn('llm.rate_limited', { requestId })
    } else {
      // 真正的异常,扔给 Sentry
      log.error('llm.failed', { requestId, error: err.message })
      Sentry.captureException(err)
    }
  }
})

6. 小结

错误日志的关键原则:

  1. 分层:业务错误走 warn,系统错误走 error,致命错误走 error + 告警
  2. 完整:记录 message、stack、cause,方便排查
  3. 克制:Sentry 只收需要人工介入的异常
  4. 全局兜底onError 捕获所有未处理的异常,但业务代码应该自己处理已知错误

下一节讲 AI 调用日志,看看怎么记录 LLM 的请求和响应。

21.03-错误日志 - Hono AI 项目知识体系 - AI共学社