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