Files
anthropic-proxy/internal/logging/logging.go
T
2026-04-14 10:31:56 +02:00

157 lines
4.0 KiB
Go

package logging
import (
"context"
"encoding/json"
"io"
"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)
// Extra writers (e.g., OTLP log bridge) are added via io.MultiWriter so logs
// are written to both the primary destination and any extra writers.
func Setup(cfg Config, extraWriters ...io.Writer) 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,
}
var w io.Writer = jack
if len(extraWriters) > 0 {
w = io.MultiWriter(append([]io.Writer{jack}, extraWriters...)...)
}
logger = zerolog.New(w).With().Timestamp().Caller().Logger()
} else {
fi, err := os.Stderr.Stat()
isTTY := err == nil && (fi.Mode()&os.ModeCharDevice) != 0
if isTTY {
// Dev mode: colored console (extra writers get JSON, console gets pretty)
cw := zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: time.RFC3339,
}
var w io.Writer = cw
if len(extraWriters) > 0 {
w = io.MultiWriter(append([]io.Writer{cw}, extraWriters...)...)
}
logger = zerolog.New(w).With().Timestamp().Caller().Logger()
} else {
// Systemd journal: JSON to stderr
var w io.Writer = os.Stderr
if len(extraWriters) > 0 {
w = io.MultiWriter(append([]io.Writer{os.Stderr}, extraWriters...)...)
}
logger = zerolog.New(w).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)
}