feat(logging): add zerolog + lumberjack structured logging package
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user