Structured wide-event logging using Go Slog

通过 Go Slog 实现结构化宽事件日志的最佳实践

前言

最近有较多的时间思考开发上的最佳实践,考虑到目前开发的一个后端项目基本就是拿 go 原生 log 到处打印与储存片面事件而已。

logger.Info("Some Error happened")

在阅读以下文件与最佳实践后,我想着手改善现有的 logging 体验透过导入 Go 1.21 引入的原生 slog 库🔗

最佳典范定义

  • 结构化 log:储存为 JSON 或类似格式的 log。
  • 宽事件:一种理念,将某事件(请求)对应一个包含所有上下文的 log。
  • 基数(Cardinality):log 数值的丰富可能性。
    • 高基数:如 user_id,独特容易抓出问题所在但耗费成本记录。
    • 低基数:如 http_method (GET, POST, PUT, DELETE, etc.),常见且重复。

实际导入结构化宽事件 Log 到 Go Gin 专案

取一个待办事项的 CRUD 专案为例,实际范例代码可以参考:Logger PR🔗

internal/logger/
├── logger.go # 初始化全局 Logger
├── middleware.go # 宽事件组合 middleware
└── context.go # 业务上下文累积工具

logger.go:初始化 Logger

首先建立 logger.go,根据执行环境选择不同的 Handler🔗,slog 的 Handler 其实就是一个接口处理「log 要怎么被处理和输出」:

logger/logger.go
var Log *slog.Logger
func InitLogger() {
var handler slog.Handler
if gin.Mode() == gin.DebugMode {
handler = tint.NewHandler(os.Stdout, &tint.Options{
Level: slog.LevelDebug,
TimeFormat: time.Kitchen,
})
} else {
handler = slog.NewJSONHandler(&lumberjack.Logger{
Filename: "logs/app.log",
MaxSize: 10,
MaxBackups: 5,
MaxAge: 30,
Compress: true,
}, &slog.HandlerOptions{Level: slog.LevelInfo})
}
Log = slog.New(handler.WithAttrs([]slog.Attr{
slog.String("service", "go-gin-testing-todos"),
}))
slog.SetDefault(Log)
}

开发环境使用 tint🔗 输出带颜色与美化日期的文字格式 生产环境输出 JSON 并透过 Gin 官方文件推荐的 lumberjack🔗 做 log rotation。

WithAttrs🔗 让每一笔 log 都自动带上 service 字段,可以塞入一些全局低基数的常见信息像是:地区、服务名称、环境变量⋯⋯等。

middleware.go:组合宽事件

透过把单一请求中发生的事件对应到一笔 log 组成宽事件,实作上透过 gin middleware 在请求结束后统一组装记录于 Logger:

logger/middleware.go
package logger
import (
"log/slog"
"time"
"github.com/gin-gonic/gin"
)
// Base on Structured Logging from gin doc
// https://gin-gonic.com/en/docs/logging/structured-logging/
// WideEventMiddleware intercepts requests and emits a single wide event upon completion.
func WideEventMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
statusCode := c.Writer.Status()
requestID, _ := c.Get("requestId")
requestIDStr, _ := requestID.(string)
fields := []slog.Attr{
slog.String("method", c.Request.Method),
slog.Int("status_code", statusCode),
slog.String("path", c.Request.URL.Path),
slog.String("query", c.Request.URL.RawQuery),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
slog.String("client_ip", c.ClientIP()),
slog.String("requestId", requestIDStr),
}
// Add Error to log
if len(c.Errors) > 0 {
fields = append(fields, slog.String("error_message", c.Errors.Last().Error()))
}
msg := "request_completed"
switch {
case statusCode >= 500:
Log.LogAttrs(c.Request.Context(), slog.LevelError, msg, fields...)
case statusCode >= 400:
Log.LogAttrs(c.Request.Context(), slog.LevelWarn, msg, fields...)
default:
Log.LogAttrs(c.Request.Context(), slog.LevelInfo, msg, fields...)
}
}
}

c.Next() 让所有请求工作先跑完,middleware 才在最后收集所有信息一次性输出。这样就不会有「一个请求散落多笔 log」的问题。

context.go:业务上下文累积

请求执行过程中,透过 AddContext 把业务信息写入 gin context,等 middleware 结束时一起带出:

logger/context.go
package logger
import (
"log/slog"
"github.com/gin-gonic/gin"
)
const businessContextKey = "logger_context"
// AddContext appends typed slog attributes to the request's business context.
func AddContext(c *gin.Context, attrs ...slog.Attr) {
if len(attrs) == 0 {
return
}
ctxAttrs := make([]slog.Attr, 0, len(attrs))
if existing, exists := c.Get(businessContextKey); exists {
if existingAttrs, ok := existing.([]slog.Attr); ok {
ctxAttrs = append(ctxAttrs, existingAttrs...)
}
}
ctxAttrs = append(ctxAttrs, attrs...)
c.Set(businessContextKey, ctxAttrs)
}
// GetContext retrieves the accumulated business context from the request.
// Returns a copy so callers cannot mutate the context state.
func GetContext(c *gin.Context) []slog.Attr {
if existing, exists := c.Get(businessContextKey); exists {
if attrs, ok := existing.([]slog.Attr); ok {
return append([]slog.Attr(nil), attrs...)
}
}
return nil
}
todo_controller.go
func (c *TodoController) Create(ctx *gin.Context) {
var todo model.Todo
if err := ctx.ShouldBindJSON(&todo); err != nil {
ctx.Error(err)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
logger.AddContext(ctx, slog.String("todo_title", todo.Title))
if err := c.service.Create(&todo); err != nil {
ctx.Error(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
logger.AddContext(ctx, slog.String("todo_id", todo.ID.Hex()))
ctx.JSON(http.StatusCreated, todo)
}

注意 todo_id 是在 Create 成功后才加入,这展示了宽事件的弹性:上下文可以在请求生命周期中逐步累积,不需要在一开始就记录信息。

直接 log

要直接拿 logger 实例来操作也是没问题的,上面只是透过 gin context 储存请求发生的状态并透过 middleware 把重复的上下文给统一收集起来处理而已。

todo_service.go
func (s *todoService) Create(todo *model.Todo) error {
logger.Log.Debug("todo_create",
slog.String("collection", "todos"),
slog.String("operation", "InsertOne"),
slog.Any("document", todo),
)
// ...
}

main.go:装上 logger 系统

func main() {
logger.InitLogger()
r := gin.New() // 不用 gin.Default(),避免 gin 内建 logger 重复输出
r.Use(gin.Recovery())
r.Use(logger.WideEventMiddleware())
// ...
}

使用 gin.New() 而非 gin.Default(),因为 gin.Default() 内建了自己的 logger middleware,会和我们的宽事件 middleware 重复输出。

输出结果

一个请求一笔 log,所有上下文都清晰地记录呈现

透过 slog.NewJSONHandler 生产环境(JSON)结果范例:

logs/app.log
{
"time": "2026-05-06T13:40:44.192568+08:00",
"level": "INFO",
"msg": "todo_read",
"service": "go-gin-testing-todos",
"method": "GET",
"status_code": 200,
"path": "/todos",
"query": "",
"duration_ms": 4,
"client_ip": "::1",
"requestId": "076f59e0-2ca3-40e1-8789-7c45708707d5",
"resource": "todo",
"action": "read",
"todos_count": 12
}

透过自制的开发环境 Handler 结果范例:

Terminal window
12:55PM DBG Get All Todos service=go-gin-testing-todos collection=todos operation=Find filter=map[]
12:56PM ERR todo_read service=go-gin-testing-todos method=GET status_code=500 path=/todos query="" duration_ms=30001 client_ip=::1 requestId=2d02fee1-6912-4799-bd91-e1718ee0ab7b resource=todo action=read error_message="server selection error: server selection timeout, current topology: { Type: Unknown, Servers: [{ Addr: localhost:27017, Type: Unknown, Last error: dial tcp [::1]:27017: connect: connection refused }, ] }"

总结

Handlers

pkg/logger

Middleware

产生 ID

收集字段

AddContext

RequestIDMiddleware

StructuredWideEvent

Structured Logger (slog)

Business Context

Console (tint)

File (lumberjack)

MongoDB Handler

Syslog Handler

考量到现成套件使用人数不多,像是:sloggin🔗gin-contrib/slog🔗samber/slog-gin🔗,我自己在 slog 之上添加了轻量的抽象让收集请求 log 变得更容易。未来可以考虑 Tail-based Sampling、异步更新 log 来解决不同延伸问题。