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 日志
}
// ...
})这样做的问题是:
onError会把所有throw当成系统错误,记录 error 日志- Sentry 会收到大量「messages is required」,淹没真正的异常
- 返回 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. 小结
错误日志的关键原则:
- 分层:业务错误走 warn,系统错误走 error,致命错误走 error + 告警
- 完整:记录 message、stack、cause,方便排查
- 克制:Sentry 只收需要人工介入的异常
- 全局兜底:
onError捕获所有未处理的异常,但业务代码应该自己处理已知错误
下一节讲 AI 调用日志,看看怎么记录 LLM 的请求和响应。