From Chaos to Clarity: Optimize Your Go API Logging with Gin


I genuinely enjoy reading logs. There’s something satisfying about tracing issues down to the exact line where things went sideways. Over the years, tools like Datadog, CloudWatch, and Logtail have been my go-to platforms for monitoring and log analysis. But let’s be honest: logs can get really annoying when they’re noisy, inconsistent, or just plain hard to read. Maybe I’ll do a separate post on how to actually read logs well and what makes for good logging practices but that’s a conversation for another day.
In the meantime, let’s talk about something that’s close to home for me: Gin . I’ve been using Gin for nearly every API I’ve built in Go over the past five years. It’s fast, lightweight, and gets out of your way—which I love. But recently, while digging through logs on our Datadog account, I noticed something that made me pause:
This was coming from a health check pinging /api
every few microseconds. The result? An overwhelming flood of “clean” 200 logs—making it painfully hard to spot real errors or follow what was actually happening in the app. That was my wake-up call: we needed a better way to log requests. One that gave us useful, structured, and filtered information.
I plugged in Hans Zimmer’s Live in Prague album and went searching for how to fix the issue. I realized we were using ginzerolog (a wrapper around Zerolog for Gin) and our setup looked something like this:
import (
ginzerolog "github.com/dn365/gin-zerolog"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/requestid"
"github.com/gin-gonic/gin"
)
func main() {
// some code here
....
// initiate gin
r := gin.New()
corsConfig := cors.DefaultConfig()
corsConfig.AllowHeaders = []string{"*"}
corsConfig.AllowAllOrigins = true
r.Use(cors.New(corsConfig), gin.Recovery())
r.Use(ginzerolog.Logger("rest"), gin.LoggerWithConfig(gin.LoggerConfig{
SkipPaths: []string{"/", "/api", "/api/v1"}, // already set to skip the root and other paths
}))
r.Use(GinContextToContextMiddleware())
r.Use(requestid.New()) // this embeds a request-id for every request
....
// other code below
}
But there were two issues:
This package is not currently maintained and outdated.
I still had logs being pushed, but it was in human readable format like this:
[GIN] 2025/04/17 - 01:23:14 | 404 | 8.561µs | 154.81.156.54 | GET "/"
My approach to fixing this
It’s tough to work with logs that aren’t in structured JSON format - especially when debugging in production or piping logs to systems like Datadog or Logtail.
So, I decided to roll out a custom logging middleware to fix that.
// LoggerMiddleware is a Gin middleware that logs HTTP requests in structured JSON format using Zerolog.
//
// It captures method, path, status, latency, client IP, user agent, and any errors encountered during the request.
// Based on the status code, it logs at different levels:
// - 2xx/3xx: Info
// - 4xx: Warn
// - 5xx: Error
//
// You can optionally provide multiple paths to skip logging (e.g., health checks) via `skipPaths`.
// This helps reduce log noise while maintaining visibility into critical endpoints.
func LoggerMiddleware(logger zerolog.Logger, skipPaths ...string) gin.HandlerFunc {
// Convert slice to a map for fast lookup (if paths are provided)
skipMap := make(map[string]bool)
if len(skipPaths) > 0 {
for _, path := range skipPaths {
skipMap[path] = true
}
}
return func(c *gin.Context) {
// we want to skip logging if path is in skipPaths (only if skipPaths were provided)
if len(skipPaths) > 0 {
if _, shouldSkip := skipMap[c.FullPath()]; shouldSkip {
c.Next()
return
}
}
start := time.Now()
c.Next() // process request
latency := time.Since(start).Milliseconds()
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
userAgent := c.Request.UserAgent()
if userAgent == "" {
userAgent = "unknown"
}
logEntry := logger.With().
Str("key", "rest").
Str("request_id", requestid.Get(c)).
Str("method", c.Request.Method).
Str("endpoint", c.FullPath()).
Int("status", c.Writer.Status()).
Int64("latency_ms", latency).
Str("client_ip", c.ClientIP()).
Str("user_agent", userAgent).
Str("error", errorMessage).
Logger()
// Log at the appropriate level
status := c.Writer.Status()
if status >= 500 {
logEntry.Error().Msg("server error")
} else if status >= 400 {
logEntry.Warn().Msg("client error")
} else {
logEntry.Info().Msg("request processed")
}
}
}
This LoggerMiddleware
function is a custom Gin middleware that logs every HTTP request in structured JSON format using Zerolog (you can use other logging libraries). It’s designed to capture critical metadata about each request such as the endpoint, method, status code, client IP, response time, and any errors - and log them at the appropriate severity level (Info, Warn, or Error).
What makes it efficient and practical:
It uses
c.FullPath()
to get the route template instead of the raw path, allowing for cleaner and grouped logs.It supports skipping logging for specific paths (like health checks) to avoid noise in production logs.
It ensures consistent request context logging, including
requestID
, for better traceability across distributed systems.It distinguishes between server errors (5xx), client errors (4xx), and successful responses (2xx/3xx) using log levels, which makes filtering in observability tools much easier
Cool right?
I also took the liberty to write a test for this:
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
)
// TestLoggerMiddleware checks if logging works and skips paths correctly
func TestLoggerMiddleware(t *testing.T) {
// Capture logs in a buffer
var logBuffer bytes.Buffer
logger := zerolog.New(&logBuffer).With().Timestamp().Logger()
// Define paths to skip
skipPaths := []string{"/health", "/metrics"}
// Create a test Gin router
gin.SetMode(gin.TestMode)
r := gin.New()
// Apply LoggerMiddleware with skip paths
r.Use(LoggerMiddleware(logger, skipPaths...))
// Add test routes
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
r.GET("/users", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": "JohnDoe"})
})
// Test: Request to a skipped path ("/health") should NOT log
logBuffer.Reset()
req1, _ := http.NewRequest("GET", "/health", nil)
w1 := httptest.NewRecorder()
r.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
assert.Empty(t, logBuffer.String(), "Expected no logs for skipped path")
// Test: Request to a non-skipped path ("/users") should log
logBuffer.Reset()
req2, _ := http.NewRequest("GET", "/users", nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
logOutput := logBuffer.String()
assert.NotEmpty(t, logOutput, "Expected log entry for non-skipped path")
assert.True(t, strings.Contains(logOutput, `"endpoint":"/users"`), "Expected logged path in output")
}
Wrapping Up
Logging isn’t just about printing messages to a terminal—it’s about building visibility into your systems. Clean, structured logs help you understand what’s happening in your app, trace issues faster, and keep your team productive. They definetely also future-proof your applications. By integrating structured logging with Zerolog and middleware like the one we just walked through, you’re giving yourself and your team the tools to debug faster, monitor smarter, and build with confidence.
Whether you’re chasing down a nasty 500 in production or just want visibility into user behavior, good logs can be the difference between flying blind and flying high.
By rolling out this custom middleware, I was able to eliminate noisy log clutter, skip unnecessary health check logs, and finally get rich, JSON-formatted logs that actually mean something in tools like Datadog.
Whether you’re scaling a microservice or just trying to survive a production fire, logging done right is a silent lifesaver.
So, log like you mean it. And treat your logs like the product they really are.
Subscribe to my newsletter
Read articles from Kehinde ODETOLA directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
