1package logging
2
3import (
4 "io"
5 "os"
6 "path/filepath"
7 "time"
8
9 "github.com/rs/zerolog"
10)
11
12const (
13 logFormatConsole = "console"
14 logFormatJSON = "json"
15 logFormatText = "text"
16 ConsoleTimeFormat = "15:04:05"
17)
18
19// Config holds logging configuration
20type Config struct {
21 Level zerolog.Level
22 Format string // "json" or "console"
23 TimeFormat string
24}
25
26// FileConfig holds file logging configuration
27type FileConfig struct {
28 Enabled bool
29 LogDir string
30 SessionID string
31 WriteToStderr bool // if false, logs go to file only (not stderr)
32}
33
34// DefaultConfig returns sensible defaults
35func DefaultConfig() Config {
36 return Config{
37 Level: zerolog.InfoLevel,
38 Format: logFormatConsole,
39 TimeFormat: time.RFC3339,
40 }
41}
42
43// New creates a new zerolog logger with the given configuration
44func New(cfg Config) zerolog.Logger {
45 var output io.Writer = os.Stderr
46
47 switch cfg.Format {
48 case logFormatConsole:
49 output = zerolog.ConsoleWriter{
50 Out: os.Stderr,
51 TimeFormat: cfg.TimeFormat,
52 }
53 case logFormatJSON:
54 // JSON is the default zerolog format
55 output = os.Stderr
56 }
57
58 return zerolog.New(output).
59 Level(cfg.Level).
60 With().
61 Timestamp().
62 Logger()
63}
64
65// NewWithFile creates a logger that writes to stderr and/or a session log file.
66// The session file uses JSON format for easy parsing by the CLI logs command.
67// LogDir must exist before calling this function (handled by config.EnsureDirectories).
68// Returns the logger and a cleanup function to close the file.
69func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
70 const (
71 logDirPerm = 0o755
72 logFilePerm = 0o644
73 )
74 var writers []io.Writer
75 cleanup := func() {}
76
77 // Stderr output (only if enabled)
78 if fileCfg.WriteToStderr {
79 switch cfg.Format {
80 case logFormatConsole, logFormatText, "":
81 writers = append(writers, zerolog.ConsoleWriter{
82 Out: os.Stderr,
83 TimeFormat: cfg.TimeFormat,
84 })
85 case logFormatJSON:
86 writers = append(writers, os.Stderr)
87 default:
88 writers = append(writers, zerolog.ConsoleWriter{
89 Out: os.Stderr,
90 TimeFormat: cfg.TimeFormat,
91 })
92 }
93 }
94
95 // Session file output (JSON format for parsing)
96 if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
97 // Ensure log directory exists (LogDir is from config, may not have logs subdir)
98 if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {
99 return zerolog.Logger{}, nil, err
100 }
101
102 filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
103 file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)
104 if err != nil {
105 return zerolog.Logger{}, nil, err
106 }
107
108 writers = append(writers, file)
109 cleanup = func() { _ = file.Close() }
110 }
111
112 // Fallback to discard if no writers configured
113 if len(writers) == 0 {
114 writers = append(writers, io.Discard)
115 }
116
117 multi := zerolog.MultiLevelWriter(writers...)
118 ctx := zerolog.New(multi).
119 Level(cfg.Level).
120 With().
121 Timestamp()
122
123 return ctx.Logger(), cleanup, nil
124}
125
126// NewFromEnv creates a logger based on environment variables
127// DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)
128// DUMBER_LOG_FORMAT: json, console (default: console)
129func NewFromEnv() zerolog.Logger {
130 cfg := DefaultConfig()
131
132 if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {
133 cfg.Level = ParseLevel(level)
134 }
135
136 if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" {
137 switch format {
138 case logFormatJSON, logFormatConsole, logFormatText:
139 cfg.Format = format
140 }
141 }
142
143 return New(cfg)
144}
145
146// NewFromConfigValues creates a logger from level and format strings.
147// This is used by main.go to create a logger from the config package's LoggingConfig
148// without creating an import cycle.
149func NewFromConfigValues(level, format string) zerolog.Logger {
150 return NewFromConfigValuesWithTimeFormat(level, format, "")
151}
152
153// NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format.
154func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger {
155 cfg := DefaultConfig()
156 cfg.Level = ParseLevel(level)
157 if timeFormat != "" {
158 cfg.TimeFormat = timeFormat
159 }
160
161 switch format {
162 case logFormatJSON:
163 cfg.Format = logFormatJSON
164 case logFormatConsole, logFormatText, "":
165 cfg.Format = logFormatConsole
166 default:
167 cfg.Format = logFormatConsole
168 }
169
170 return New(cfg)
171}
172
173// ParseLevel converts a level string to zerolog.Level
174func ParseLevel(level string) zerolog.Level {
175 switch level {
176 case "trace":
177 return zerolog.TraceLevel
178 case "debug":
179 return zerolog.DebugLevel
180 case "info", "":
181 return zerolog.InfoLevel
182 case "warn":
183 return zerolog.WarnLevel
184 case "error":
185 return zerolog.ErrorLevel
186 case "fatal":
187 return zerolog.FatalLevel
188 default:
189 return zerolog.InfoLevel
190 }
191}