日志中间件

要点

  • Hono 内置的 logger() 中间件提供开箱即用的请求日志
  • 默认输出请求方法、路径、状态码和耗时,适合开发阶段调试
  • 生产环境通常需要结构化日志,logger() 支持自定义输出函数
  • 日志中间件应放在中间件栈的最外层,确保被拦截的请求也有记录
  • 可以结合 request-id 中间件,在日志里携带追踪 ID
  • 常见的生产日志方案:pinowinston、JSON 格式输出

1. 默认用法

logger() 是 Hono 内置的日志中间件,直接挂载就能用:

// src/index.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
 
const app = new Hono()
 
app.use('*', logger())
 
app.get('/', (c) => c.text('Hello'))
app.get('/users', (c) => c.json([{ id: 1, name: 'Alice' }]))
app.get('/error', () => { throw new Error('oops') })
 
export default app

请求时控制台输出:

// output.txt
--> GET / 200 1ms
--> GET /users 200 2ms
--> GET /error 500 3ms

每条日志包含:方向箭头、HTTP 方法、路径、状态码、耗时。--> 表示响应完成。如果是请求到达,还会打印 <-- GET /users

logger() 默认使用 console.log() 输出。在开发阶段足够用,但在生产环境有几个局限:

  1. 输出是纯文本,不便解析
  2. 没有时间戳
  3. 没有请求 ID,无法关联上下游日志
  4. 无法按日志级别过滤

2. 自定义日志输出

logger() 接收一个 PrintFunc 函数,可以替换默认的输出行为:

// src/middleware/logger.ts
import { logger } from 'hono/logger'
import type { Log } from 'hono/logger'
 
type PrintFunc = (
  str: string,
  ...rest: string[]
) => void
 
const customLogger: PrintFunc = (str, ...rest) => {
  // str 是格式化后的日志字符串
  // rest 是额外的参数
  console.log(`[CUSTOM] ${str}`, ...rest)
}
 
// src/index.ts
import { Hono } from 'hono'
import { customLogger } from './middleware/logger'
 
const app = new Hono()
app.use('*', logger(customLogger))
 
export default app

不过 PrintFunc 接收的是已经格式化好的字符串,不方便做结构化输出。更彻底的做法是写一个自定义的日志中间件。

3. 自定义日志中间件

如果需要对日志格式有完全的控制,可以自己写一个日志中间件:

// src/middleware/custom-logger.ts
import { createMiddleware } from 'hono/factory'
 
export const customLogger = createMiddleware(async (c, next) => {
  const start = Date.now()
  const requestId = c.get('requestId') as string | undefined
 
  await next()
 
  const duration = Date.now() - start
  const status = c.res.status
 
  // JSON 格式输出,便于日志采集工具解析
  console.log(JSON.stringify({
    time: new Date().toISOString(),
    requestId: requestId ?? '-',
    method: c.req.method,
    path: c.req.path,
    status,
    duration: `${duration}ms`,
    userAgent: c.req.header('user-agent') ?? '-',
  }))
})
// src/index.ts
import { Hono } from 'hono'
import { requestId } from 'hono/request-id'
import { customLogger } from './middleware/custom-logger'
 
const app = new Hono()
 
// requestId 在 customLogger 之前,这样日志可以携带 requestId
app.use('*', requestId())
app.use('*', customLogger)
 
app.get('/', (c) => c.text('Hello'))
 
export default app

输出:

{
  "time": "2026-06-20T10:00:00.000Z",
  "requestId": "abc123",
  "method": "GET",
  "path": "/",
  "status": 200,
  "duration": "2ms",
  "userAgent": "Mozilla/5.0"
}

JSON 格式日志可以被 jqfluentdloki 等工具直接解析,比纯文本格式更适合生产环境。

4. 接入 pino

pino 是 Node.js 生态里性能较好的 JSON 日志库。接入方式:

// src/middleware/pino-logger.ts
import { createMiddleware } from 'hono/factory'
import pino from 'pino'
 
const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
})
 
export const pinoLogger = createMiddleware(async (c, next) => {
  const start = Date.now()
  const requestId = c.get('requestId') as string | undefined
 
  await next()
 
  const duration = Date.now() - start
 
  logger.info({
    requestId: requestId ?? '-',
    method: c.req.method,
    path: c.req.path,
    status: c.res.status,
    duration,
    userAgent: c.req.header('user-agent') ?? '-',
  }, 'request completed')
})

pino 的优势:

  1. 输出固定 JSON 格式,字段类型精确(duration 是数字,不是字符串)
  2. 支持日志级别(tracedebuginfowarnerror
  3. 性能经过优化,高吞吐场景下比 console.log 快一个数量级
  4. 配合 pino-pretty 可以在开发阶段格式化输出

5. 错误日志

日志中间件通常只记录请求完成的信息。如果路由抛出异常,中间件的后段不会执行(错误冒泡到了 app.onError()),日志可能缺失。

两种处理方式:

5.1 在 onError 里记录

// src/index.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
app.onError((err, c) => {
  console.error(JSON.stringify({
    time: new Date().toISOString(),
    method: c.req.method,
    path: c.req.path,
    error: err.message,
    stack: err.stack,
  }))
  return c.json({ error: 'Internal Server Error' }, 500)
})

5.2 在中间件里 try-catch

// src/middleware/custom-logger.ts
import { createMiddleware } from 'hono/factory'
 
export const customLogger = createMiddleware(async (c, next) => {
  const start = Date.now()
 
  try {
    await next()
  } catch (err) {
    // 记录错误日志
    console.error(JSON.stringify({
      time: new Date().toISOString(),
      method: c.req.method,
      path: c.req.path,
      error: (err as Error).message,
    }))
    // 继续抛给 app.onError() 处理
    throw err
  }
 
  const duration = Date.now() - start
  console.log(JSON.stringify({
    time: new Date().toISOString(),
    method: c.req.method,
    path: c.req.path,
    status: c.res.status,
    duration: `${duration}ms`,
  }))
})

第一种方式更简洁,错误处理集中在 onError;第二种方式让日志中间件自己处理错误日志,职责更内聚。两种方式可以并存——onError 负责返回统一格式的错误响应,日志中间件负责记录日志。

6. 日志级别

生产环境通常需要按级别过滤日志,避免信息过载:

// src/middleware/pino-logger.ts
import { createMiddleware } from 'hono/factory'
import pino from 'pino'
 
const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
})
 
export const pinoLogger = createMiddleware(async (c, next) => {
  const start = Date.now()
 
  // 请求到达时记录 debug 级别
  logger.debug({
    method: c.req.method,
    path: c.req.path,
  }, 'request started')
 
  await next()
 
  const duration = Date.now() - start
  const status = c.res.status
 
  // 根据状态码选择日志级别
  const logLevel = status >= 500
    ? 'error'
    : status >= 400
    ? 'warn'
    : 'info'
 
  logger[logLevel]({
    method: c.req.method,
    path: c.req.path,
    status,
    duration,
  }, 'request completed')
})

通过环境变量 LOG_LEVEL=debug 可以在开发阶段看到更详细的日志,生产环境设置为 infowarn 减少输出量。

7. 敏感信息脱敏

日志里不应该出现密码、token、身份证号等敏感信息。如果请求体包含这些字段,需要在记录前脱敏:

// src/middleware/custom-logger.ts
import { createMiddleware } from 'hono/factory'
 
const SENSITIVE_KEYS = ['password', 'token', 'secret', 'authorization']
 
function redact(obj: Record<string, unknown>): Record<string, unknown> {
  const result: Record<string, unknown> = {}
  for (const [key, value] of Object.entries(obj)) {
    if (SENSITIVE_KEYS.includes(key.toLowerCase())) {
      result[key] = '[REDACTED]'
    } else if (typeof value === 'object' && value !== null) {
      result[key] = redact(value as Record<string, unknown>)
    } else {
      result[key] = value
    }
  }
  return result
}
 
export const customLogger = createMiddleware(async (c, next) => {
  const start = Date.now()
 
  await next()
 
  const duration = Date.now() - start
  const headers = redact(Object.fromEntries(c.req.raw.headers))
 
  console.log(JSON.stringify({
    time: new Date().toISOString(),
    method: c.req.method,
    path: c.req.path,
    status: c.res.status,
    duration: `${duration}ms`,
    headers,
  }))
})

pino 内置了 pino-std-serializers 和 redact 功能,可以直接配置脱敏路径,不需要手动实现:

const logger = pino({
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'body.password',
    ],
    censor: '[REDACTED]',
  },
})

8. 日志采集

生产环境的日志通常需要采集到集中式日志系统。常见方案:

  1. stdout + 日志采集器:应用输出到 stdout,由 fluentdlokivector 等工具采集
  2. 文件日志 + logrotate:输出到文件,配合日志轮转和采集
  3. 直接推送到日志服务:使用日志服务的 SDK 直接写入(阿里云 SLS、AWS CloudWatch 等)

对于 stdout 方案,JSON 格式日志是最友好的输出方式。采集器可以按行解析 JSON,提取字段建立索引。

Hono 跑在 Cloudflare Workers、Deno Deploy 等边缘运行时,stdout 不可用。这种情况下通常使用运行时的日志 API,或者直接把日志写到远程服务。

总结

日志中间件是 Hono 中间件栈的第一层,负责记录每个请求的到达、处理和完成。

这一节涉及到的几个层次:

  1. 内置 logger():开发阶段够用,输出纯文本,生产环境需要替换
  2. 自定义日志中间件:JSON 格式输出,便于日志采集工具解析
  3. 接入 pino:生产环境推荐,支持日志级别、高性能 JSON 输出
  4. 错误日志:在 onError 或中间件 try-catch 里记录
  5. 日志级别:根据状态码动态选择级别,通过环境变量控制详细程度
  6. 敏感信息脱敏:日志里不应出现密码、token 等字段
  7. 日志采集:stdout + 采集器是最常见的生产方案

下一篇看 CORS 中间件——跨域配置、preflight 处理、credentials 模式。