Skip to content
Go
Gin
日志与监控

日志与监控

本章覆盖 Gin 的日志系统:写入文件、轮转、自定义格式、查询参数脱敏、结构化日志(slog/zerolog),以及 Prometheus 指标监控。


日志写入文件

默认情况下,Gin 将所有日志输出到 os.Stdout。通过设置 gin.DefaultWriter 重定向日志:

func main() {
    // 禁用控制台彩色输出(写入文件时不需要)
    gin.DisableConsoleColor()

    f, _ := os.Create("gin.log")
    gin.DefaultWriter = io.MultiWriter(f)

    // 同时写入文件和控制台
    // gin.DefaultWriter = io.MultiWriter(f, os.Stdout)

    router := gin.Default()
    router.GET("/ping", func(c *gin.Context) {
        c.String(200, "pong")
    })
    router.Run(":8080")
}

💡 io.MultiWriter 可以组合多个写入目标,同时输出到文件和控制台。


日志轮转

使用 lumberjack 实现按大小和时间的日志轮转:

go get gopkg.in/natefinch/lumberjack.v2
import "gopkg.in/natefinch/lumberjack.v2"

func main() {
    gin.DisableConsoleColor()

    gin.DefaultWriter = &lumberjack.Logger{
        Filename:   "gin.log",   // 日志文件路径
        MaxSize:    100,          // 单文件最大 MB
        MaxBackups: 3,            // 最多保留旧文件数
        MaxAge:     28,           // 最多保留天数
        Compress:   true,         // 旧文件是否压缩
    }

    router := gin.Default()
    router.Run(":8080")
}

自定义日志格式

使用 gin.LoggerWithFormatter 自定义日志输出格式:

router := gin.New()
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
    return fmt.Sprintf("[%s] %s %s %d %v %s\n",
        param.TimeStamp.Format(time.RFC3339),
        param.Method,
        param.Path,
        param.StatusCode,
        param.Latency,
        param.ClientIP,
    )
}))

LogFormatterParams 字段

字段 类型 说明
TimeStamp time.Time 请求开始时间
StatusCode int HTTP 状态码
Latency time.Duration 请求耗时
ClientIP string 客户端 IP
Method string HTTP 方法
Path string 请求路径
ErrorMessage string 错误信息
BodySize int 响应体大小
Keys map[string]any 上下文中设置的值

跳过日志记录

loggerConfig := gin.LoggerConfig{
    // 跳过指定路径
    SkipPaths: []string{"/metrics", "/healthz"},

    // 按条件跳过
    Skip: func(c *gin.Context) bool {
        // 只记录 5xx 错误
        return c.Writer.Status() < http.StatusInternalServerError
    },
}

router.Use(gin.LoggerWithConfig(loggerConfig))

查询参数脱敏

查询字符串常包含 API Token、密码等敏感信息。SkipQueryString 可从日志中剥离查询字符串:

loggerConfig := gin.LoggerConfig{SkipQueryString: true}
router.Use(gin.LoggerWithConfig(loggerConfig))

router.GET("/search", func(c *gin.Context) {
    q := c.Query("q") // 仍可正常访问
    c.String(200, "searching: "+q)
})
// 日志: GET /search → 200
// 而非: GET /search?q=secret&token=abc → 200

💡 这仅影响日志输出,c.Query() 仍可正常获取参数。


自定义路由日志格式

启动时 Gin 在 debug 模式下打印所有注册的路由。可通过 gin.DebugPrintRouteFunc 自定义格式:

gin.DebugPrintRouteFunc = func(httpMethod, absolutePath, handlerName string, nuHandlers int) {
    log.Printf("Route: %s %s → %s (%d handlers)\n",
        httpMethod, absolutePath, handlerName, nuHandlers)
}

结构化日志

结构化日志生成机器可读的 JSON 输出,便于 ELK、Datadog、Grafana Loki 等日志聚合系统查询。

使用 Go 标准库 slog(Go 1.21+)

func SlogMiddleware(logger *slog.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        path := c.Request.URL.Path
        query := c.Request.URL.RawQuery

        c.Next()

        logger.Info("request",
            slog.String("method", c.Request.Method),
            slog.String("path", path),
            slog.String("query", query),
            slog.Int("status", c.Writer.Status()),
            slog.Duration("latency", time.Since(start)),
            slog.String("client_ip", c.ClientIP()),
            slog.Int("body_size", c.Writer.Size()),
        )

        if len(c.Errors) > 0 {
            for _, err := range c.Errors {
                logger.Error("request error", slog.String("error", err.Error()))
            }
        }
    }
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    r := gin.New()
    r.Use(SlogMiddleware(logger))
    r.Use(gin.Recovery())
    r.Run(":8080")
}

输出:

{"time":"2024-01-15T10:30:00Z","level":"INFO","msg":"request","method":"GET","path":"/ping","query":"","status":200,"latency":"125µs","client_ip":"127.0.0.1","body_size":18}

添加请求 ID / 关联 ID

跨服务追踪请求:

func RequestIDMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        requestID := c.GetHeader("X-Request-ID")
        if requestID == "" {
            requestID = uuid.New().String()
        }
        c.Set("request_id", requestID)
        c.Header("X-Request-ID", requestID)
        c.Next()
    }
}

使用 zerolog

zerolog 是高性能零分配 JSON 日志库:

import (
    "github.com/rs/zerolog"
    "github.com/rs/zerolog/log"
)

func ZerologMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()

        log.Info().
            Str("method", c.Request.Method).
            Str("path", c.Request.URL.Path).
            Int("status", c.Writer.Status()).
            Dur("latency", time.Since(start)).
            Str("client_ip", c.ClientIP()).
            Msg("request")
    }
}

func main() {
    // 开发环境使用人类可读格式
    if gin.Mode() == gin.DebugMode {
        log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
    }
    // ...
}

日志最佳实践

  • 生产环境使用 JSON 格式 — 开发时人类可读,生产时 JSON 聚合
  • 包含请求 ID — 传播 X-Request-ID 头用于分布式追踪
  • 使用适当日志级别 — 正常请求 Info,4xx Warn,5xx Error
  • 避免记录敏感数据 — 排除密码、Token、PII
  • 设置 GIN_MODE=release — 在生产中禁用调试日志

指标与监控

使用 gin-contrib/openmetrics

go get github.com/gin-contrib/openmetrics
import "github.com/gin-contrib/openmetrics"

func main() {
    r := gin.Default()
    r.Use(openmetrics.NewOpenMetrics("myapp", openmetrics.WithExpose()))
    r.Run(":8080")
}
// 自动暴露 /metrics 端点供 Prometheus 抓取

自定义 Prometheus 指标

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    httpRequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total number of HTTP requests",
        },
        []string{"method", "path", "status"},
    )

    httpRequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request duration in seconds",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path"},
    )
)

func init() {
    prometheus.MustRegister(httpRequestsTotal, httpRequestDuration)
}

func MetricsMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()

        duration := time.Since(start).Seconds()
        status := strconv.Itoa(c.Writer.Status())

        httpRequestsTotal.WithLabelValues(c.Request.Method, c.FullPath(), status).Inc()
        httpRequestDuration.WithLabelValues(c.Request.Method, c.FullPath()).Observe(duration)
    }
}

func main() {
    r := gin.Default()
    r.Use(MetricsMiddleware())
    r.GET("/metrics", gin.WrapH(promhttp.Handler()))
    r.Run(":8080")
}

🚨 陷阱:使用 c.FullPath()(如 /user/:id)而不是 c.Request.URL.Path(如 /user/123)作为路径标签。否则会因标签值过多导致 Prometheus 性能问题(高基数问题)。

关键指标

指标 类型 用途
http_requests_total Counter 按方法、路径、状态分类的总请求数
http_request_duration_seconds Histogram 请求延迟分布
http_requests_in_flight Gauge 当前正在处理的请求数
http_response_size_bytes Histogram 响应体大小分布

独立端口提供指标

生产环境建议通过独立端口暴露指标:

func main() {
    app := gin.Default()
    // 应用路由...

    // 指标服务运行在独立端口(仅内网可访问)
    go func() {
        metrics := http.NewServeMux()
        metrics.Handle("/metrics", promhttp.Handler())
        http.ListenAndServe(":9090", metrics)
    }()

    app.Run(":8080") // 公网端口
}

Prometheus 抓取配置

scrape_configs:
  - job_name: "gin-app"
    scrape_interval: 15s
    static_configs:
      - targets: ["localhost:9090"]