package logger import ( "fmt" "io" "os" "path/filepath" "runtime" "strconv" "strings" "sync" "github.com/rs/zerolog" "golang.org/x/term" ) type LogLevel = zerolog.Level const ( DEBUG = zerolog.DebugLevel INFO = zerolog.InfoLevel WARN = zerolog.WarnLevel ERROR = zerolog.ErrorLevel FATAL = zerolog.FatalLevel Component = "component" ) var ( logLevelNames = map[LogLevel]string{ DEBUG: "DEBUG", INFO: "INFO", WARN: "WARN", ERROR: "ERROR", FATAL: "FATAL", } currentLevel = INFO logger zerolog.Logger logFile *os.File once sync.Once mu sync.RWMutex writers []io.Writer consoleWriter zerolog.ConsoleWriter ) func init() { once.Do(func() { zerolog.SetGlobalLevel(zerolog.InfoLevel) isTTY := term.IsTerminal(int(os.Stdout.Fd())) consoleWriter = zerolog.ConsoleWriter{ Out: os.Stdout, TimeFormat: "15:04:05", // TODO: make it configurable??? // Custom formatter to handle multiline strings and JSON objects FormatFieldValue: formatFieldValue, PartsOrder: []string{ zerolog.TimestampFieldName, zerolog.LevelFieldName, Component, zerolog.CallerFieldName, zerolog.MessageFieldName, }, FieldsExclude: []string{Component}, FormatPrepare: func(fields map[string]any) error { if isTTY { fields[Component] = fmt.Sprintf("\x1b[33m%v\x1b[0m", fields[Component]) } return nil }, NoColor: !isTTY, } writers = append(writers, consoleWriter) logger = zerolog.New(io.MultiWriter(writers...)).With().Timestamp().Caller().Logger() }) } func formatFieldValue(i any) string { var s string switch val := i.(type) { case string: s = val case []byte: s = string(val) default: return fmt.Sprintf("%v", i) } if unquoted, err := strconv.Unquote(s); err == nil { s = unquoted } if strings.Contains(s, "\n") { return fmt.Sprintf("\n%s", s) } if strings.Contains(s, " ") { if (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) || (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) { return s } return fmt.Sprintf("%q", s) } return s } func SetLevel(level LogLevel) { mu.Lock() defer mu.Unlock() currentLevel = level zerolog.SetGlobalLevel(level) } func SetConsoleLevel(level LogLevel) { mu.Lock() defer mu.Unlock() logger = logger.Level(level) } func DisableConsole() { mu.Lock() defer mu.Unlock() writers[0] = io.Discard logger = logger.Output(io.MultiWriter(writers...)) } func EnableConsole() { mu.Lock() defer mu.Unlock() writers[0] = consoleWriter logger = logger.Output(io.MultiWriter(writers...)) } func GetLevel() LogLevel { mu.RLock() defer mu.RUnlock() return currentLevel } // ParseLevel converts a case-insensitive level name to a LogLevel. // Returns the level and true if valid, or (INFO, false) if unrecognized. func ParseLevel(s string) (LogLevel, bool) { switch strings.ToLower(strings.TrimSpace(s)) { case "debug": return DEBUG, true case "info": return INFO, true case "warn", "warning": return WARN, true case "error": return ERROR, true case "fatal": return FATAL, true default: return INFO, false } } // SetLevelFromString sets the log level from a string value. // If the string is empty or not a recognized level name, the current level is kept. func SetLevelFromString(s string) { if s == "" { return } if level, ok := ParseLevel(s); ok { SetLevel(level) } } func EnableFileLogging(filePath string) error { mu.Lock() defer mu.Unlock() if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { return fmt.Errorf("failed to create log directory: %w", err) } newFile, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } // Close old file if exists if logFile != nil { logFile.Close() } logFile = newFile if len(writers) != 1 { return fmt.Errorf("failed to configure file logging: %w", err) } writers = append(writers, logFile) logger = logger.Output(io.MultiWriter(writers...)) return nil } func DisableFileLogging() { mu.Lock() defer mu.Unlock() if logFile != nil { logFile.Close() logFile = nil } if len(writers) > 1 { writers = writers[:1] logger = logger.Output(io.MultiWriter(writers...)) } } func ConfigureFromEnv() { if logFile := os.Getenv("PICOCLAW_LOG_FILE"); logFile != "" { if strings.HasPrefix(logFile, "~/") { if home := os.Getenv("HOME"); home != "" { logFile = filepath.Join(home, logFile[2:]) } } if err := EnableFileLogging(logFile); err != nil { fmt.Fprintf(os.Stderr, "failed to enable file logging: %v\n", err) } else { DisableConsole() } } } const ( locUnknown = "" ) func getPackageNameFromFile(filePath string) string { dir := filepath.Dir(filePath) importPath := filepath.ToSlash(dir) parts := strings.Split(importPath, "/") if len(parts) == 0 { return locUnknown } pkg := parts[len(parts)-1] if pkg == "." { return "
" } return pkg } func getCallerSkip() (int, string) { for i := 2; i < 15; i++ { pc, file, _, ok := runtime.Caller(i) if !ok { continue } fn := runtime.FuncForPC(pc) if fn == nil { continue } // bypass common loggers if strings.HasSuffix(file, "/logger.go") || strings.HasSuffix(file, "/logger_3rd_party.go") || strings.HasSuffix(file, "/log.go") { continue } funcName := fn.Name() if strings.HasPrefix(funcName, "runtime.") { continue } return i - 1, getPackageNameFromFile(file) } return 3, locUnknown } //nolint:zerologlint func getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event { switch level { case zerolog.DebugLevel: return logger.Debug() case zerolog.InfoLevel: return logger.Info() case zerolog.WarnLevel: return logger.Warn() case zerolog.ErrorLevel: return logger.Error() case zerolog.FatalLevel: return logger.Fatal() default: return logger.Info() } } func logMessage(level LogLevel, component string, message string, fields map[string]any) { if level < currentLevel { return } skip, pkg := getCallerSkip() event := getEvent(logger, level) if component == "" { component = pkg } event.Str(Component, component) appendFields(event, fields) event.CallerSkipFrame(skip).Msg(message) } func appendFields(event *zerolog.Event, fields map[string]any) { for k, v := range fields { // Type switch to avoid double JSON serialization of strings switch val := v.(type) { case error: event.Str(k, val.Error()) case string: event.Str(k, val) case int: event.Int(k, val) case int64: event.Int64(k, val) case float64: event.Float64(k, val) case bool: event.Bool(k, val) default: event.Interface(k, v) // Fallback for struct, slice and maps } } } func Debug(message string) { logMessage(DEBUG, "", message, nil) } func DebugC(component string, message string) { logMessage(DEBUG, component, message, nil) } func Debugf(message string, ss ...any) { logMessage(DEBUG, "", fmt.Sprintf(message, ss...), nil) } func DebugF(message string, fields map[string]any) { logMessage(DEBUG, "", message, fields) } func DebugCF(component string, message string, fields map[string]any) { logMessage(DEBUG, component, message, fields) } func Info(message string) { logMessage(INFO, "", message, nil) } func InfoC(component string, message string) { logMessage(INFO, component, message, nil) } func InfoF(message string, fields map[string]any) { logMessage(INFO, "", message, fields) } func Infof(message string, ss ...any) { logMessage(INFO, "", fmt.Sprintf(message, ss...), nil) } func InfoCF(component string, message string, fields map[string]any) { logMessage(INFO, component, message, fields) } func Warn(message string) { logMessage(WARN, "", message, nil) } func WarnC(component string, message string) { logMessage(WARN, component, message, nil) } func WarnF(message string, fields map[string]any) { logMessage(WARN, "", message, fields) } func WarnCF(component string, message string, fields map[string]any) { logMessage(WARN, component, message, fields) } func Warnf(message string, ss ...any) { logMessage(WARN, "", fmt.Sprintf(message, ss...), nil) } func Error(message string) { logMessage(ERROR, "", message, nil) } func ErrorC(component string, message string) { logMessage(ERROR, component, message, nil) } func Errorf(message string, ss ...any) { logMessage(ERROR, "", fmt.Sprintf(message, ss...), nil) } func ErrorF(message string, fields map[string]any) { logMessage(ERROR, "", message, fields) } func ErrorCF(component string, message string, fields map[string]any) { logMessage(ERROR, component, message, fields) } func Fatal(message string) { logMessage(FATAL, "", message, nil) } func FatalC(component string, message string) { logMessage(FATAL, component, message, nil) } func Fatalf(message string, ss ...any) { logMessage(FATAL, "", fmt.Sprintf(message, ss...), nil) } func FatalF(message string, fields map[string]any) { logMessage(FATAL, "", message, fields) } func FatalCF(component string, message string, fields map[string]any) { logMessage(FATAL, component, message, fields) }