日志与监控
本章覆盖 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.v2import "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,4xxWarn,5xxError - 避免记录敏感数据 — 排除密码、Token、PII
- 设置
GIN_MODE=release— 在生产中禁用调试日志
指标与监控
使用 gin-contrib/openmetrics
go get github.com/gin-contrib/openmetricsimport "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"]