Hono 工程化实战 02:日志、Request ID 和可观测性
API 上线以后,最怕的不是报错。
最怕的是报错了,但你不知道发生了什么。
用户说:
你去看日志,只有一句:
没有请求路径,没有用户,没有租户,没有 request id,没有耗时,没有错误栈。
这不是日志。
这是谜语。
Hono 项目要进生产,必须把日志和可观测性当成基础设施来做。
这篇文章讲 Hono API 里最实用的一套可观测性做法:Request ID、结构化日志、耗时统计、错误日志、业务事件日志。
技术栈快照时间:2026-06-23。Hono logger、request-id、timing middleware 会更新,生产使用前以官方文档为准。
一、先理解可观测性的目标
可观测性不是把日志打得越多越好。
它要解决三个问题:
对 API 来说,至少要能回答:
1 2 3 4 5 6 7 8
| 哪个请求失败了 状态码是多少 耗时是多少 哪个用户触发的 属于哪个租户 错误栈在哪里 有没有关联的后台任务 有没有调用第三方服务
|
如果日志不能帮助你定位问题,那它只是噪音。
二、每个请求都要有 Request ID
Request ID 是排查问题的线头。
一个请求进入系统时生成一个 ID,之后所有相关日志都带上它。
Hono 有内置 request-id middleware。
你也可以自己写一个简单版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import { Hono } from 'hono'
type Variables = { requestId: string }
const app = new Hono<{ Variables: Variables }>()
app.use('*', async (c, next) => { const requestId = c.req.header('X-Request-ID') ?? crypto.randomUUID()
c.set('requestId', requestId) c.header('X-Request-ID', requestId)
await next() })
|
这样前端收到错误时,可以把 request id 一起上报:
1 2 3 4 5 6 7
| { "error": { "code": "INTERNAL_SERVER_ERROR", "message": "服务器内部错误" }, "requestId": "01JZ8Y8MQZ8C7N5F9W1T" }
|
用户反馈问题时,你就能直接按 request id 查日志。
三、用结构化日志,不要拼字符串
不要这样写:
1
| console.log(`user ${user.id} create post ${post.id}`)
|
更推荐结构化日志:
1 2 3 4 5 6
| console.info('post_created', { requestId: c.get('requestId'), userId: user.id, tenantId: tenant.id, postId: post.id, })
|
结构化日志的好处是:
1 2 3 4
| 可以按字段搜索 可以做统计 可以聚合告警 更容易接入日志平台
|
日志消息用稳定事件名:
1 2 3 4 5
| request_completed auth_failed post_created payment_webhook_received job_failed
|
不要每次换一种写法。
稳定事件名会让排查和统计都轻很多。
四、写一个请求日志 middleware
Hono 内置 logger 很适合本地开发。
生产环境更推荐结构化请求日志。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| app.use('*', async (c, next) => { const start = Date.now() const requestId = c.get('requestId')
await next()
const durationMs = Date.now() - start
console.info('request_completed', { requestId, method: c.req.method, path: new URL(c.req.url).pathname, status: c.res.status, durationMs, userAgent: c.req.header('user-agent'), }) })
|
这样每个请求都会有一条完成日志。
字段不要太多。
建议基础字段:
1 2 3 4 5 6 7 8 9
| requestId method path status durationMs userId tenantId ip userAgent
|
如果还没鉴权,userId 可以为空。
五、错误日志要带上下文
Hono 的 app.onError() 是统一错误日志的好地方。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| app.onError((err, c) => { const requestId = c.get('requestId')
console.error('unhandled_error', { requestId, method: c.req.method, path: new URL(c.req.url).pathname, message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, })
return c.json({ error: { code: 'INTERNAL_SERVER_ERROR', message: '服务器内部错误', }, requestId, }, 500) })
|
注意两点。
第一,错误栈可以进服务端日志,但不要返回给前端。
第二,日志里要有 request id。
否则你看到错误栈,也很难知道它对应的是哪个用户请求。
六、业务错误和系统错误分开
不是所有错误都应该打成 console.error。
比如:
这些是业务预期内的错误。
可以记录为 warn 或 info。
真正需要 error 的通常是:
1 2 3 4 5
| 数据库连接失败 第三方接口异常 未知异常 队列任务连续失败 数据状态不一致
|
可以定义一个 AppError:
1 2 3 4 5 6 7 8 9
| class AppError extends Error { constructor( public code: string, public status: number, message: string ) { super(message) } }
|
统一处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| app.onError((err, c) => { const requestId = c.get('requestId')
if (err instanceof AppError) { console.warn('app_error', { requestId, code: err.code, status: err.status, message: err.message, })
return c.json({ error: { code: err.code, message: err.message, }, requestId, }, err.status) }
console.error('unhandled_error', { requestId, message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, })
return c.json({ error: { code: 'INTERNAL_SERVER_ERROR', message: '服务器内部错误', }, requestId, }, 500) })
|
这样日志等级会更干净。
你不会被大量 404、401 淹没真正的系统错误。
七、耗时统计要分层
只知道请求总耗时还不够。
比如一个请求耗时 2 秒,你还想知道慢在哪里:
1 2 3 4
| 数据库查询慢 第三方 API 慢 对象存储慢 业务计算慢
|
可以写一个简单计时工具:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| async function measure<T>( name: string, fn: () => Promise<T>, meta: Record<string, unknown> = {} ) { const start = Date.now()
try { return await fn() } finally { console.info('operation_timing', { name, durationMs: Date.now() - start, ...meta, }) } }
|
使用:
1 2 3 4 5 6 7 8
| const post = await measure( 'db.posts.findById', () => posts.findById(id), { requestId: c.get('requestId'), postId: id, } )
|
Hono 也有 Server-Timing middleware,可以把部分耗时写到响应 header,方便浏览器 DevTools 查看。
生产里我更建议:
1 2
| 开发调试:Server-Timing 线上分析:结构化日志 + 指标系统
|
八、不要记录敏感信息
日志里不要出现:
1 2 3 4 5 6 7 8 9
| 密码 token session cookie 身份证号 银行卡 完整手机号 完整邮箱 第三方 secret Authorization header
|
请求日志尤其要小心。
不要把所有 header 和 body 直接打出来:
1
| console.log(await c.req.json())
|
这很危险。
如果必须记录 body,也要做脱敏:
1 2 3 4 5 6 7
| function redact(input: Record<string, unknown>) { return { ...input, password: input.password ? '[REDACTED]' : undefined, token: input.token ? '[REDACTED]' : undefined, } }
|
更好的方式是只记录业务必要字段:
1 2 3 4 5
| console.info('login_failed', { requestId, emailHash, reason: 'invalid_password', })
|
日志是长期留存的数据。
不要把它变成另一个泄露风险。
九、业务事件日志
除了请求日志,还应该有业务事件日志。
比如:
1 2 3 4 5 6 7
| user_registered user_logged_in post_published payment_succeeded tenant_plan_changed member_invited api_key_created
|
业务事件日志的价值是:
示例:
1 2 3 4 5 6 7
| console.info('tenant_plan_changed', { requestId: c.get('requestId'), tenantId: tenant.id, actorUserId: user.id, fromPlan: 'free', toPlan: 'pro', })
|
不要把业务事件混在普通请求日志里。
请求日志回答“请求发生了什么”。
业务事件回答“业务发生了什么”。
十、后台任务也要串起来
如果 API 创建了后台任务,日志要能串起来。
创建任务时:
1 2 3 4 5 6
| await c.env.REPORT_QUEUE.send({ jobId, requestId: c.get('requestId'), tenantId: tenant.id, userId: user.id, })
|
消费者处理时:
1 2 3 4 5
| console.info('job_started', { jobId: message.body.jobId, requestId: message.body.requestId, tenantId: message.body.tenantId, })
|
这样你就能从一个 API 请求追到后台任务。
否则用户说“我点了导出没反应”,你只能在请求日志和任务日志之间靠猜。
十一、告警不要太晚做
最基础的告警可以先围绕这些指标:
1 2 3 4 5 6 7 8
| 5xx 数量 核心接口错误率 核心接口 P95/P99 耗时 队列积压 后台任务失败率 支付 Webhook 失败 登录失败异常升高 数据库查询异常
|
不用一开始就上复杂平台。
但至少要保证:
这是生产系统的底线。
十二、我推荐的最小可观测性模板
一个 Hono API 上线前,至少做这几件事:
1 2 3 4 5 6 7 8 9
| 1. 每个请求生成 requestId 2. 响应 header 返回 X-Request-ID 3. 请求完成时打结构化日志 4. app.onError 统一记录未知错误 5. AppError 区分业务错误和系统错误 6. 核心业务动作打事件日志 7. 后台任务继承 requestId 8. 日志脱敏 9. 给核心错误率和耗时做告警
|
这套东西不复杂。
但它会显著改变你维护系统的手感。
没有可观测性的 API,出了问题像在黑盒里摸索。
有了 request id、结构化日志和关键事件,你至少知道从哪里开始查。
Hono 很轻,但生产运维不能轻飘飘。