diff --git a/server/internal/app/config.go b/server/internal/app/config.go index f9b8e2c..61af395 100644 --- a/server/internal/app/config.go +++ b/server/internal/app/config.go @@ -43,7 +43,7 @@ func DefaultConfig() *Config { IsDevelopment: false, LogDir: "./logs", LogLevel: logging.INFO, - ConsoleOutput: true, + ConsoleOutput: false, } } @@ -134,8 +134,6 @@ func LoadConfig() (*Config, error) { if parsed, err := strconv.ParseBool(consoleOutput); err == nil { config.ConsoleOutput = parsed } - } else if config.IsDevelopment { - config.ConsoleOutput = true } // Validate all settings diff --git a/server/internal/logging/logger.go b/server/internal/logging/logger.go index b04dbbe..2135918 100644 --- a/server/internal/logging/logger.go +++ b/server/internal/logging/logger.go @@ -1,41 +1,100 @@ -// Package logging provides a logging interface for the application. +// Package logging provides a structured logging interface for the application. package logging import ( + "context" "fmt" "io" + "log/slog" "os" "path/filepath" "time" ) -// LogLevel defines the severity of a log message -type LogLevel int +// LogLevel represents the logging level +type LogLevel slog.Level // Log levels const ( - DEBUG LogLevel = iota - INFO - WARN - ERROR + DEBUG LogLevel = LogLevel(slog.LevelDebug) + INFO LogLevel = LogLevel(slog.LevelInfo) + WARN LogLevel = LogLevel(slog.LevelWarn) + ERROR LogLevel = LogLevel(slog.LevelError) ) -func (l LogLevel) String() string { - switch l { - case DEBUG: - return "DEBUG" - case INFO: - return "INFO" - case WARN: - return "WARN" - case ERROR: - return "ERROR" - default: - return "INFO" - } +// Logger defines the interface for logging operations +type Logger interface { + App() *slog.Logger // Returns logger for application logs + Audit() *slog.Logger // Returns logger for audit logs + Security() *slog.Logger // Returns logger for security logs + Close() error // Cleanup and close log files } -// ParseLogLevel converts a string to a LogLevel +// logger implements the Logger interface +type logger struct { + appLogger *slog.Logger + auditLogger *slog.Logger + securityLogger *slog.Logger + files []*os.File // Keep track of open file handles +} + +// Output represents a destination for logs +type Output struct { + Type OutputFormat + Writer io.Writer +} + +// OutputFormat represents the format of the log output +type OutputFormat int + +const ( + OutputTypeJSON OutputFormat = iota // OutputTypeJSON JSON format + OutputTypeText // OutputTypeText text format +) + +// createLogFile creates a log file with the given name in the log directory +func createLogFile(logDir, name string) (*os.File, error) { + if err := os.MkdirAll(logDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + + filename := filepath.Join(logDir, fmt.Sprintf("%s.log", name)) + file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + + return file, nil +} + +// createLogger creates a new slog.Handler for the given outputs and options +func createLogger(opts *slog.HandlerOptions, outputs []Output) slog.Handler { + if len(outputs) == 0 { + return slog.NewTextHandler(io.Discard, opts) + } + + if len(outputs) == 1 { + output := outputs[0] + if output.Type == OutputTypeJSON { + return slog.NewJSONHandler(output.Writer, opts) + } + return slog.NewTextHandler(output.Writer, opts) + } + + // Multiple outputs - create handlers for each + handlers := make([]slog.Handler, 0, len(outputs)) + for _, output := range outputs { + if output.Type == OutputTypeJSON { + handlers = append(handlers, slog.NewJSONHandler(output.Writer, opts)) + } else { + handlers = append(handlers, slog.NewTextHandler(output.Writer, opts)) + } + } + + return multiHandler(handlers) +} + +// ParseLogLevel parses a string into a LogLevel func ParseLogLevel(level string) (LogLevel, error) { switch level { case "DEBUG": @@ -51,210 +110,120 @@ func ParseLogLevel(level string) (LogLevel, error) { } } -// Logger defines the interface for all logging operations -type Logger interface { - // Standard logging - Debug(msg string, fields map[string]interface{}) - Info(msg string, fields map[string]interface{}) - Warn(msg string, fields map[string]interface{}) - Error(err error, fields map[string]interface{}) - - // Audit logging (always at INFO level) - Audit(userID int, workspaceID int, action, resource string, details map[string]interface{}) - - // Security logging (with levels) - Security(level LogLevel, userID int, event string, details map[string]interface{}) - - // Close closes all outputs - Close() error -} - -type logger struct { - logDir string - minLevel LogLevel - consoleOut bool - appOutput io.Writer // Combined output for application logs - auditOutput io.Writer // Output for audit logs - secOutput io.Writer // Output for security logs -} - -type logEntry struct { - Timestamp time.Time - Level LogLevel - Message string - Fields map[string]interface{} -} - // New creates a new Logger instance func New(logDir string, minLevel LogLevel, consoleOut bool) (Logger, error) { - if err := os.MkdirAll(logDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create log directory: %w", err) - } - l := &logger{ - logDir: logDir, - minLevel: minLevel, - consoleOut: consoleOut, + files: make([]*os.File, 0, 3), } - // Setup application log output - appFile, err := os.OpenFile( - filepath.Join(logDir, "app.log"), - os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0644, - ) - if err != nil { - return nil, fmt.Errorf("failed to open app log file: %w", err) + // Define our log types and their filenames + logTypes := []struct { + name string + setLogger func(*slog.Logger) + }{ + {"app", func(lg *slog.Logger) { l.appLogger = lg }}, + {"audit", func(lg *slog.Logger) { l.auditLogger = lg }}, + {"security", func(lg *slog.Logger) { l.securityLogger = lg }}, } - // Setup audit log output - auditFile, err := os.OpenFile( - filepath.Join(logDir, "audit.log"), - os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0644, - ) - if err != nil { - return nil, fmt.Errorf("failed to open audit log file: %w", err) + // Setup handlers options + opts := &slog.HandlerOptions{ + Level: slog.Level(minLevel), + AddSource: slog.Level(minLevel) == slog.LevelDebug, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.Attr{ + Key: a.Key, + Value: slog.StringValue(time.Now().UTC().Format(time.RFC3339)), + } + } + return a + }, } - // Setup security log output - securityFile, err := os.OpenFile( - filepath.Join(logDir, "security.log"), - os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0644, - ) - if err != nil { - return nil, fmt.Errorf("failed to open security log file: %w", err) - } + // Create loggers for each type + for _, lt := range logTypes { + // Create file output + file, err := createLogFile(logDir, lt.name) + if err != nil { + if err := l.Close(); err != nil { + return nil, fmt.Errorf("failed to close logger: %w", err) + } + return nil, fmt.Errorf("failed to create %s log file: %w", lt.name, err) + } + l.files = append(l.files, file) - // Configure outputs - l.appOutput = appFile - if consoleOut { - l.appOutput = io.MultiWriter(appFile, os.Stdout) - } + // Prepare outputs + outputs := []Output{{Type: OutputTypeJSON, Writer: file}} + if consoleOut { + outputs = append(outputs, Output{Type: OutputTypeText, Writer: os.Stdout}) + } - l.auditOutput = auditFile - l.secOutput = io.MultiWriter(securityFile, os.Stderr) // Security logs always go to stderr + // Create and set logger + handler := createLogger(opts, outputs) + lt.setLogger(slog.New(handler)) + } return l, nil } -// writeToOutput writes to the output and handles errors appropriately -func (l *logger) writeOutput(w io.Writer, format string, args ...interface{}) { - _, err := fmt.Fprintf(w, format, args...) - if err != nil { - // Log to stderr if writing fails - fmt.Fprintf(os.Stderr, "logging error: %v\n", err) - } +func (l *logger) App() *slog.Logger { + return l.appLogger } -func (l *logger) logApp(level LogLevel, msg string, fields map[string]interface{}) { - if level < l.minLevel { - return - } - - entry := logEntry{ - Timestamp: time.Now(), - Level: level, - Message: msg, - Fields: fields, - } - - l.writeOutput(l.appOutput, "[%s] [%s] %s%s\n", - entry.Timestamp.Format(time.RFC3339), - entry.Level, - entry.Message, - formatFields(entry.Fields), - ) +func (l *logger) Audit() *slog.Logger { + return l.auditLogger } -// Debug logs a debug message -func (l *logger) Debug(msg string, fields map[string]interface{}) { - l.logApp(DEBUG, msg, fields) -} - -// Info logs an informational message -func (l *logger) Info(msg string, fields map[string]interface{}) { - l.logApp(INFO, msg, fields) -} - -// Warn logs a warning message -func (l *logger) Warn(msg string, fields map[string]interface{}) { - l.logApp(WARN, msg, fields) -} - -// Error logs an error message -func (l *logger) Error(err error, fields map[string]interface{}) { - if fields == nil { - fields = make(map[string]interface{}) - } - fields["error"] = err.Error() - l.logApp(ERROR, "error occurred", fields) -} - -// Audit logs an audit event -func (l *logger) Audit(userID int, workspaceID int, action, resource string, details map[string]interface{}) { - if details == nil { - details = make(map[string]interface{}) - } - details["user_id"] = userID - details["workspace_id"] = workspaceID - details["action"] = action - details["resource"] = resource - - l.writeOutput(l.auditOutput, "[%s] [AUDIT] %s on %s%s\n", - time.Now().Format(time.RFC3339), - action, - resource, - formatFields(details), - ) -} - -// Security logs a security event -func (l *logger) Security(level LogLevel, userID int, event string, details map[string]interface{}) { - if level < l.minLevel { - return - } - - l.writeOutput(l.secOutput, "[%s] [%s] [%d] [SECURITY] %s%s\n", - time.Now().Format(time.RFC3339), - level, - userID, - event, - formatFields(details), - ) +func (l *logger) Security() *slog.Logger { + return l.securityLogger } func (l *logger) Close() error { - var errs []error - - if closer, ok := l.appOutput.(io.Closer); ok { - if err := closer.Close(); err != nil { - errs = append(errs, fmt.Errorf("failed to close app output: %w", err)) + var lastErr error + for _, file := range l.files { + if file != nil { + if err := file.Close(); err != nil { + lastErr = err + } } } + return lastErr +} - if closer, ok := l.auditOutput.(io.Closer); ok { - if err := closer.Close(); err != nil { - errs = append(errs, fmt.Errorf("failed to close audit output: %w", err)) +// multiHandler implements slog.Handler for multiple outputs +type multiHandler []slog.Handler + +func (h multiHandler) Enabled(ctx context.Context, level slog.Level) bool { + for _, handler := range h { + if handler.Enabled(ctx, level) { + return true } } + return false +} - if closer, ok := l.secOutput.(io.Closer); ok { - if err := closer.Close(); err != nil { - errs = append(errs, fmt.Errorf("failed to close security output: %w", err)) +func (h multiHandler) Handle(ctx context.Context, r slog.Record) error { + for _, handler := range h { + if err := handler.Handle(ctx, r); err != nil { + return err } } - - if len(errs) > 0 { - return fmt.Errorf("failed to close some outputs: %v", errs) - } return nil } -func formatFields(fields map[string]interface{}) string { - if len(fields) == 0 { - return "" +func (h multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + handlers := make([]slog.Handler, len(h)) + for i, handler := range h { + handlers[i] = handler.WithAttrs(attrs) } - return fmt.Sprintf(" - Fields: %v", fields) + return multiHandler(handlers) +} + +func (h multiHandler) WithGroup(name string) slog.Handler { + handlers := make([]slog.Handler, len(h)) + for i, handler := range h { + handlers[i] = handler.WithGroup(name) + } + return multiHandler(handlers) }