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.
- Structured Logging with slog
- Logging sucks. And here’s how to make it better. - Boris Tane
- Logging - gin
- Custom log file - gin
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.
- High cardinality: e.g.
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 utilitieslogger.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”:
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:
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:
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}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.
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:
{ "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:
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
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.
- slog-multi is useful for managing multiple Handlers
- slog-syslog provides an existing syslog Handler