From a7b583839da6a8261bcd65e062234ee9c75dabe1 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 10 Apr 2026 18:15:49 +0200 Subject: [PATCH] feat(logging): add zerolog + lumberjack structured logging package --- go.mod | 3 + go.sum | 6 ++ internal/logging/logging.go | 141 ++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 internal/logging/logging.go diff --git a/go.mod b/go.mod index c57abb6..e1880b6 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -33,6 +34,7 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/refraction-networking/utls v1.8.2 // indirect + github.com/rs/zerolog v1.35.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect @@ -45,4 +47,5 @@ require ( golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/lumberjack.v2 v2.0.0 // indirect ) diff --git a/go.sum b/go.sum index 6a6129f..6c54977 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -65,6 +67,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= +github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -117,6 +121,8 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/lumberjack.v2 v2.0.0 h1:IDj6hi8KbNiPQ5VaYNFZ7dBJLF5LFeKvsFrWHjA5aq4= +gopkg.in/lumberjack.v2 v2.0.0/go.mod h1:bp5nQ2kK/lLQSmTk29azj9+JB6bWci56xFn/lvd5GLI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..c2401ca --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,141 @@ +package logging + +import ( + "context" + "encoding/json" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "gopkg.in/lumberjack.v2" +) + +// Config holds logging configuration, mirrors config.LoggingConfig. +type Config struct { + Level string + File string + MaxSizeMB int + MaxBackups int + MaxAgeDays int + Compress bool +} + +// Setup initializes the global zerolog logger. +// - File set: JSON → lumberjack rotating file +// - File empty + TTY: colored ConsoleWriter → stderr +// - File empty + not TTY: JSON → stderr (for systemd journal) +func Setup(cfg Config) zerolog.Logger { + // Parse log level + level, err := zerolog.ParseLevel(cfg.Level) + if err != nil || cfg.Level == "" { + level = zerolog.InfoLevel + } + zerolog.SetGlobalLevel(level) + zerolog.TimeFieldFormat = time.RFC3339 + + var logger zerolog.Logger + + if cfg.File != "" { + // Production: JSON to rotating file + jack := &lumberjack.Logger{ + Filename: cfg.File, + MaxSize: cfg.MaxSizeMB, + MaxBackups: cfg.MaxBackups, + MaxAge: cfg.MaxAgeDays, + Compress: cfg.Compress, + } + logger = zerolog.New(jack).With().Timestamp().Caller().Logger() + } else { + fi, err := os.Stderr.Stat() + isTTY := err == nil && (fi.Mode()&os.ModeCharDevice) != 0 + if isTTY { + // Dev mode: colored console + cw := zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: time.RFC3339, + } + logger = zerolog.New(cw).With().Timestamp().Caller().Logger() + } else { + // Systemd journal: JSON to stderr + logger = zerolog.New(os.Stderr).With().Timestamp().Caller().Logger() + } + } + + // Set global + log.Logger = logger + return logger +} + +// GinRequestLogger returns a Gin middleware that logs every request with zerolog. +// Logs AFTER the request completes. +// Level: Info for 2xx, Warn for 4xx, Error for 5xx +func GinRequestLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + method := c.Request.Method + + c.Next() + + status := c.Writer.Status() + latencyMs := float64(time.Since(start).Microseconds()) / 1000.0 + clientIP := c.ClientIP() + requestID := c.Writer.Header().Get("X-Request-Id") + if requestID == "" { + requestID = c.GetHeader("x-client-request-id") + } + + evt := log.Logger.WithLevel(statusLevel(status)). + Str("method", method). + Str("path", path). + Int("status", status). + Float64("latency_ms", latencyMs). + Str("client_ip", clientIP) + + if requestID != "" { + evt = evt.Str("request_id", requestID) + } + + evt.Msg("request") + } +} + +func statusLevel(status int) zerolog.Level { + switch { + case status >= 500: + return zerolog.ErrorLevel + case status >= 400: + return zerolog.WarnLevel + default: + return zerolog.InfoLevel + } +} + +// FromContext returns the zerolog logger from context, or the global logger. +func FromContext(ctx context.Context) *zerolog.Logger { + l := zerolog.Ctx(ctx) + if l.GetLevel() == zerolog.Disabled { + return &log.Logger + } + return l +} + +// RedactHeaders serializes HTTP headers to a JSON string, +// replacing Authorization and x-api-key values with "***". +func RedactHeaders(h http.Header) string { + redacted := make(map[string]string, len(h)) + for k, v := range h { + key := strings.ToLower(k) + if key == "authorization" || key == "x-api-key" { + redacted[k] = "***" + } else { + redacted[k] = strings.Join(v, ", ") + } + } + b, _ := json.Marshal(redacted) + return string(b) +}