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