Files
kandev/apps/backend/internal/common/logger/logger.go
T

292 lines
8.6 KiB
Go

// Package logger provides structured logging using go.uber.org/zap.
package logger
import (
"context"
"os"
"path/filepath"
"sync"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
"github.com/kandev/kandev/internal/common/logger/buffer"
)
// Context keys for extracting values from context.
type contextKey string
const (
CorrelationIDKey contextKey = "correlation_id"
RequestIDKey contextKey = "request_id"
)
// LoggingConfig holds the configuration for the logger.
//
// This struct is constructed in Go (not unmarshaled via viper/mapstructure),
// so it does not carry struct tags. Callers map their own config types
// (e.g. config.LoggingConfig) into these fields explicitly.
type LoggingConfig struct {
Level string // debug, info, warn, error
Format string // json, console
OutputPath string // stdout, stderr, or file path
// Rotation options - apply only when OutputPath is a file path
// (ignored for stdout/stderr). Backed by lumberjack.
MaxSizeMB int // rotate when file exceeds this size; 0 = lumberjack default (100MB)
MaxBackups int // max number of rotated files to retain; 0 = unlimited
MaxAgeDays int // max age of rotated files; 0 = unlimited
Compress bool // gzip rotated files
}
// Logger wraps zap.Logger to provide structured logging with helper methods.
type Logger struct {
zap *zap.Logger
sugar *zap.SugaredLogger
fields []zap.Field
rotator *lumberjack.Logger // non-nil only when OutputPath is a file path
}
var (
defaultLogger *Logger
defaultLoggerOnce sync.Once
)
// Default returns the global default logger.
// This logger is initialized with default settings (info level, text format for terminals, stdout).
func Default() *Logger {
defaultLoggerOnce.Do(func() {
var err error
defaultLogger, err = NewLogger(LoggingConfig{
Level: "info",
Format: detectLogFormat(),
OutputPath: "stdout",
})
if err != nil {
// Fallback to a basic logger if configuration fails
zapLogger, _ := zap.NewProduction()
defaultLogger = &Logger{
zap: zapLogger,
sugar: zapLogger.Sugar(),
}
}
})
return defaultLogger
}
// SetDefault sets the global default logger.
func SetDefault(logger *Logger) {
defaultLogger = logger
}
// NewFromZap wraps an existing *zap.Logger in a Logger. Useful in tests
// where the caller provides a custom core (e.g. zaptest/observer).
func NewFromZap(z *zap.Logger) (*Logger, error) {
return &Logger{
zap: z,
sugar: z.Sugar(),
}, nil
}
// NewLogger creates a new Logger with the given configuration.
func NewLogger(cfg LoggingConfig) (*Logger, error) {
level, err := parseLevel(cfg.Level)
if err != nil {
level = zapcore.InfoLevel
}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "timestamp"
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.LowercaseLevelEncoder
var encoder zapcore.Encoder
// Accept both "console" and "text" as aliases for human-readable format
if cfg.Format == "console" || cfg.Format == "text" {
encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
encoder = zapcore.NewConsoleEncoder(encoderConfig)
} else {
encoder = zapcore.NewJSONEncoder(encoderConfig)
}
var (
writeSyncer zapcore.WriteSyncer
rotator *lumberjack.Logger
)
switch cfg.OutputPath {
case "", "stdout":
writeSyncer = zapcore.AddSync(os.Stdout)
case "stderr":
writeSyncer = zapcore.AddSync(os.Stderr)
default:
rotator = &lumberjack.Logger{
Filename: cfg.OutputPath,
MaxSize: cfg.MaxSizeMB,
MaxBackups: cfg.MaxBackups,
MaxAge: cfg.MaxAgeDays,
Compress: cfg.Compress,
}
writeSyncer = zapcore.AddSync(rotator)
}
outputCore := zapcore.NewCore(encoder, writeSyncer, level)
bufferCore := buffer.NewCore(buffer.Default(), level)
core := zapcore.NewTee(outputCore, bufferCore)
zapLogger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
return &Logger{
zap: zapLogger,
sugar: zapLogger.Sugar(),
rotator: rotator,
}, nil
}
func parseLevel(level string) (zapcore.Level, error) {
var l zapcore.Level
err := l.UnmarshalText([]byte(level))
return l, err
}
// detectLogFormat returns the appropriate log format based on environment.
// Returns "json" if running in Kubernetes or other production environments.
// Returns "text" for terminal/development use (human-readable console format).
func detectLogFormat() string {
// Check if running in Kubernetes
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
return "json"
}
// Check for explicit production environment
if env := os.Getenv("KANDEV_ENV"); env == "production" || env == "prod" {
return "json"
}
// Default to text format for terminal use (more readable than JSON)
return "text"
}
// Sync flushes any buffered log entries.
func (l *Logger) Sync() error {
return l.zap.Sync()
}
// Close flushes pending log entries and releases the file handle held by the
// rotating file sink, if any. Safe to call on a stdout/stderr logger (no-op).
// Call before process shutdown to ensure the final batch reaches disk and the
// rotation cycle can complete cleanly.
func (l *Logger) Close() error {
_ = l.zap.Sync()
if l.rotator != nil {
return l.rotator.Close()
}
return nil
}
// WithFields returns a new Logger with the given fields added.
// The rotator reference is propagated so Close() on a derived logger
// still releases the underlying file handle.
func (l *Logger) WithFields(fields ...zap.Field) *Logger {
z := l.zap.With(fields...)
return &Logger{
zap: z,
sugar: z.Sugar(),
fields: append(l.fields, fields...),
rotator: l.rotator,
}
}
// WithContext returns a new Logger with context values (correlation ID, etc.) added.
func (l *Logger) WithContext(ctx context.Context) *Logger {
fields := []zap.Field{}
if correlationID, ok := ctx.Value(CorrelationIDKey).(string); ok && correlationID != "" {
fields = append(fields, zap.String("correlation_id", correlationID))
}
if requestID, ok := ctx.Value(RequestIDKey).(string); ok && requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
if len(fields) == 0 {
return l
}
return l.WithFields(fields...)
}
// WithError returns a new Logger with the error field added.
func (l *Logger) WithError(err error) *Logger {
return l.WithFields(zap.Error(err))
}
// WithTaskID returns a new Logger with the task_id field added.
func (l *Logger) WithTaskID(taskID string) *Logger {
return l.WithFields(zap.String("task_id", taskID))
}
// WithAgentID returns a new Logger with the agent_id field added.
func (l *Logger) WithAgentID(agentID string) *Logger {
return l.WithFields(zap.String("agent_id", agentID))
}
// Debug logs a message at debug level with optional structured fields.
func (l *Logger) Debug(msg string, fields ...zap.Field) {
l.zap.Debug(msg, fields...)
}
// Info logs a message at info level with optional structured fields.
func (l *Logger) Info(msg string, fields ...zap.Field) {
l.zap.Info(msg, fields...)
}
// Warn logs a message at warn level with optional structured fields.
func (l *Logger) Warn(msg string, fields ...zap.Field) {
l.zap.Warn(msg, fields...)
}
// Error logs a message at error level with optional structured fields.
func (l *Logger) Error(msg string, fields ...zap.Field) {
l.zap.Error(msg, fields...)
}
// Fatal logs a message at fatal level with optional structured fields,
// then calls os.Exit(1).
func (l *Logger) Fatal(msg string, fields ...zap.Field) {
l.zap.Fatal(msg, fields...)
}
// Zap returns the underlying zap.Logger for advanced use cases.
func (l *Logger) Zap() *zap.Logger {
return l.zap
}
// Sugar returns the underlying zap.SugaredLogger for printf-style logging.
func (l *Logger) Sugar() *zap.SugaredLogger {
return l.sugar
}
// BufferSnapshot returns a copy of the entries currently held in the
// process-wide log ring buffer used by Improve Kandev reports.
func (l *Logger) BufferSnapshot() []buffer.Entry {
return buffer.Default().Snapshot()
}
// LogDirectory returns the directory containing the rotating log file managed
// by lumberjack, or empty string when the logger is writing to stdout/stderr
// (no file rotation configured). Powers the System -> Logs page enumeration.
func (l *Logger) LogDirectory() string {
if l == nil || l.rotator == nil || l.rotator.Filename == "" {
return ""
}
return filepath.Dir(l.rotator.Filename)
}
// LogFilename returns the base filename (no directory) of the active
// lumberjack log, e.g. "kandev.log". Rotated files use the same base with a
// timestamp suffix. Empty when no file logging is configured.
func (l *Logger) LogFilename() string {
if l == nil || l.rotator == nil || l.rotator.Filename == "" {
return ""
}
return filepath.Base(l.rotator.Filename)
}