21.02-请求日志

要点

  • 每个请求都需要记录基础信息:method、path、status、duration
  • 使用中间件统一处理,避免在每个路由里重复写日志代码
  • requestId 是串联跨服务日志的关键,必须在请求入口生成
  • 请求日志要结构化(JSON),方便后续查询和过滤

内容

1. 请求日志要记录什么

每个 HTTP 请求至少应该记录这几个字段:

字段含义为什么需要
methodGET/POST/PUT/DELETE区分请求类型
path请求路径定位是哪个接口
status响应状态码判断成功/失败
duration耗时(毫秒)性能监控
requestId请求唯一 ID串联同一请求的多条日志
userId / apiKeyId用户标识定位是哪个用户

可选但推荐的字段:

  • ip:客户端 IP(用于安全审计)
  • userAgent:客户端类型(浏览器/移动端/脚本)
  • referer:来源页面(分析流量来源)
  • contentLength:请求/响应体大小(排查大包问题)

2. 用中间件统一记录

不要在每个路由里手写日志,用 Hono 中间件统一处理:

// src/middleware/request-logger.ts
import { Context, Next } from 'hono'
import { log } from '../lib/logger'
 
export async function requestLogger(c: Context, next: Next) {
  const startedAt = Date.now()
  const requestId = crypto.randomUUID()
 
  // 存储到请求上下文,后续路由可以读取
  c.set('requestId', requestId)
  c.set('startedAt', startedAt)
 
  // 响应头也带上 requestId,方便前端排查
  c.header('X-Request-Id', requestId)
 
  await next()
 
  // 请求完成后记录日志
  log.info('http.request', {
    requestId,
    method: c.req.method,
    path: c.req.path,
    status: c.res.status,
    duration: Date.now() - startedAt,
    userId: c.get('userId'),  // 如果鉴权中间件已经设置
  })
}

然后在入口文件里注册:

// src/index.ts
import { Hono } from 'hono'
import { requestLogger } from './middleware/request-logger'
 
const app = new Hono()
 
// 所有请求都经过这个中间件
app.use('*', requestLogger)
 
app.get('/health', (c) => c.json({ status: 'ok' }))
app.post('/v1/chat/completions', async (c) => {
  // 这里可以直接读取 requestId
  const requestId = c.get('requestId')
  // ...
})
 
export default app

3. requestId 的作用

requestId 是排查问题的关键。一个用户请求可能触发多条日志:

http.request  requestId=abc123  method=POST  path=/v1/chat/completions
llm.call      requestId=abc123  model=gpt-4  tokens=1500
cache.miss    requestId=abc123  key=user:123:preferences
llm.success   requestId=abc123  duration=2300ms
http.request  requestId=abc123  status=200  duration=2500ms

当你看到用户反馈「刚才那个请求很慢」,可以:

  1. 从前端拿到 X-Request-Id 响应头
  2. 在日志平台搜索 requestId=abc123
  3. 看到完整的请求链路:HTTP 请求 → LLM 调用 → 缓存查询 → 响应

如果没有 requestId,你只能靠时间戳猜「这几条日志是不是同一个请求」,在高并发场景下几乎不可能。

3.1 requestId 的生成时机

必须在请求入口生成,而不是在路由里。原因:

  • 中间件在路由之前执行,可以确保所有日志都带上 requestId
  • 如果路由里抛出异常,全局错误处理器也能读取到 requestId
  • 响应头里返回 requestId,前端可以记录下来

错误做法:

// ❌ 在路由里生成,太晚了
app.post('/v1/chat/completions', async (c) => {
  const requestId = crypto.randomUUID()  // 中间件的日志还没有这个 ID
  // ...
})

正确做法:

// ✅ 在中间件里生成,所有后续代码都能读取
app.use('*', async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  await next()
})

4. 敏感信息脱敏

请求日志里不要记录敏感信息:

  • ❌ 密码、token、API key
  • ❌ 完整的信用卡号
  • ❌ 个人隐私信息(手机号、身份证号)

如果需要在日志里记录请求体,先做脱敏:

// src/lib/sanitize.ts
export function sanitizeBody(body: Record<string, unknown>): Record<string, unknown> {
  const sensitiveFields = ['password', 'token', 'apiKey', 'creditCard']
  const sanitized = { ...body }
 
  for (const field of sensitiveFields) {
    if (field in sanitized) {
      sanitized[field] = '***'
    }
  }
 
  return sanitized
}
// src/middleware/request-logger.ts
import { sanitizeBody } from '../lib/sanitize'
 
export async function requestLogger(c: Context, next: Next) {
  // ...
 
  await next()
 
  // 只记录非敏感请求体
  if (c.req.method === 'POST' || c.req.method === 'PUT') {
    const body = await c.req.json().catch(() => null)
    if (body) {
      log.info('http.request.body', {
        requestId: c.get('requestId'),
        body: sanitizeBody(body),
      })
    }
  }
}

5. 日志级别选择

不同场景使用不同级别:

场景级别示例
正常请求info200/201/204 响应
客户端错误warn400/401/403/404
服务端错误error500/502/503
慢请求warnduration > 1000ms
// src/middleware/request-logger.ts
export async function requestLogger(c: Context, next: Next) {
  // ...
 
  await next()
 
  const status = c.res.status
  const duration = Date.now() - startedAt
 
  let level: 'info' | 'warn' | 'error' = 'info'
 
  if (status >= 500) {
    level = 'error'
  } else if (status >= 400 || duration > 1000) {
    level = 'warn'
  }
 
  log[level]('http.request', {
    requestId: c.get('requestId'),
    method: c.req.method,
    path: c.req.path,
    status,
    duration,
  })
}

6. 性能影响

日志本身也有开销,需要注意:

  1. JSON.stringify 是同步的:大请求体会阻塞事件循环
  2. I/O 操作要异步:如果日志写入文件/数据库,用 await 或后台任务
  3. 采样率控制:高流量场景下不需要记录每个请求
// 采样率控制
export async function requestLogger(c: Context, next: Next) {
  const shouldLog = Math.random() < 0.1  // 只记录 10% 的请求
 
  if (!shouldLog) {
    await next()
    return
  }
 
  // ... 记录日志
}

Workers Observability 的 head_sampling_rate 也是这个原理,只不过是在平台层面做的。

7. 小结

请求日志是可观测性的基础。三个关键点:

  1. 统一中间件:不要在每个路由里手写日志
  2. requestId:在请求入口生成,响应头返回,所有日志都带上
  3. 结构化:JSON 格式,方便查询和过滤

下一节讲错误日志,看看怎么捕获和记录异常。