Structured wide-event logging using Go Slog

Introduction

I’ve recently spent more time thinking about best practices. In one backend project I’m working on, the code mostly just uses Go’s standard log🔗 to print events.

logger.Info("Some Error happened")

After reading the following docs and best practices, I decided to improve the current logging experience by introducing Go 1.21’s native slog package🔗.

Best Practices

  • Structured log: logs stored as JSON or similar formats.
  • Wide event: a concept that maps a single event (request) to one log containing all related context.
  • Cardinality: the richness/variety of log values.
    • High cardinality: e.g. user_id, unique and useful for pinpointing issues but costly to record.
    • Low cardinality: e.g. http_method (GET, POST, PUT, DELETE, etc.), common and repetitive.

Implementing structured wide-event logging in a Go Gin project

Take a todo CRUD project as an example. The example code can be found here: Logger PR🔗:

internal/logger/
├── logger.go # initialize the global Logger
├── middleware.go # wide-event assembling middleware
└── context.go # business context accumulation utilities

logger.go: initializing the Logger

First create logger.go. Choose different Handler🔗 based on the runtime environment. A slog Handler is basically an interface that handles “how logs are processed and output”:

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)
}
  • In development, use tint🔗 to output colored text with pretty time formatting.
  • In production, output JSON and use lumberjack🔗 for log rotation as recommended by the Gin docs.

WithAttrs🔗 makes every log automatically include a service field. You can add global low-cardinality info such as region, service name, environment variables, etc.

middleware.go: composing the wide event

By mapping events that happen during a single request into one log, we assemble a wide event via a Gin middleware and log it when the request completes:

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() lets the request handlers finish first; the middleware then collects all information and outputs it once at the end. This prevents “one request scattered across many logs.”

context.go: accumulating business context

During the request lifecycle, use AddContext to write business info into the Gin context; the middleware will include that when finishing:

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)
}

Note that todo_id is added only after Create succeeds. This demonstrates the flexibility of wide events: context can be accumulated progressively during the request lifecycle, and you don’t need to record everything at the start.

Direct logging

You can also use the logger instance directly. The above just stores request state in the Gin context so the middleware can collect repeated context fields and handle them uniformly.

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: wiring up the logger system

func main() {
logger.InitLogger()
r := gin.New() // don't use gin.Default(), avoid duplicate output from gin's built-in logger
r.Use(gin.Recovery())
r.Use(logger.WideEventMiddleware())
// ...
}

Use gin.New() instead of gin.Default() because gin.Default() includes its own logger middleware, which would duplicate output with our wide-event middleware.

Output examples

One log per request, with all context clearly recorded

Example production (JSON) output using slog.NewJSONHandler:

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
}

Example output using a custom development 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 }, ] }"

Conclusion

Handlers

pkg/logger

Middleware

Generate ID

Collecting Fields

AddContext

RequestIDMiddleware

StructuredWideEvent

Structured Logger (slog)

Business Context

Console (tint)

File (lumberjack)

MongoDB Handler

Syslog Handler

Given the small user base of ready-made packages such as sloggin🔗, gin-contrib/slog🔗, and samber/slog-gin🔗, I added a light abstraction over slog to make collecting request logs easier. In the future, consider tail-based sampling or asynchronous log updates to address other scaling concerns.