From ea916c3ecc14733daa7f8ba8032bd70189a65fe7 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 10 Dec 2024 22:16:50 +0100 Subject: [PATCH] Initial logging implementation --- .vscode/launch.json | 14 ++ server/internal/app/config.go | 30 ++++ server/internal/logging/logger.go | 260 ++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 server/internal/logging/logger.go diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..405bacd --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch NovaMD Server", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/server/cmd/server/main.go", + "cwd": "${workspaceFolder}", + "envFile": "${workspaceFolder}/server/.env" + } + ] +} diff --git a/server/internal/app/config.go b/server/internal/app/config.go index b9ff02a..f9b8e2c 100644 --- a/server/internal/app/config.go +++ b/server/internal/app/config.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "novamd/internal/logging" "novamd/internal/secrets" "os" "strconv" @@ -25,6 +26,9 @@ type Config struct { RateLimitRequests int RateLimitWindow time.Duration IsDevelopment bool + LogDir string + LogLevel logging.LogLevel + ConsoleOutput bool } // DefaultConfig returns a new Config instance with default values @@ -37,6 +41,9 @@ func DefaultConfig() *Config { RateLimitRequests: 100, RateLimitWindow: time.Minute * 15, IsDevelopment: false, + LogDir: "./logs", + LogLevel: logging.INFO, + ConsoleOutput: true, } } @@ -108,6 +115,29 @@ func LoadConfig() (*Config, error) { } } + // Configure log directory + if logDir := os.Getenv("NOVAMD_LOG_DIR"); logDir != "" { + config.LogDir = logDir + } + + // Configure log level, if isDevelopment is set, default to debug + if logLevel := os.Getenv("NOVAMD_LOG_LEVEL"); logLevel != "" { + if parsed, err := logging.ParseLogLevel(logLevel); err == nil { + config.LogLevel = parsed + } + } else if config.IsDevelopment { + config.LogLevel = logging.DEBUG + } + + // Configure console output, if isDevelopment is set, default to true + if consoleOutput := os.Getenv("NOVAMD_CONSOLE_OUTPUT"); consoleOutput != "" { + if parsed, err := strconv.ParseBool(consoleOutput); err == nil { + config.ConsoleOutput = parsed + } + } else if config.IsDevelopment { + config.ConsoleOutput = true + } + // Validate all settings if err := config.validate(); err != nil { return nil, err diff --git a/server/internal/logging/logger.go b/server/internal/logging/logger.go new file mode 100644 index 0000000..b04dbbe --- /dev/null +++ b/server/internal/logging/logger.go @@ -0,0 +1,260 @@ +// Package logging provides a logging interface for the application. +package logging + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +// LogLevel defines the severity of a log message +type LogLevel int + +// Log levels +const ( + DEBUG LogLevel = iota + INFO + WARN + ERROR +) + +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" + } +} + +// ParseLogLevel converts a string to a LogLevel +func ParseLogLevel(level string) (LogLevel, error) { + switch level { + case "DEBUG": + return DEBUG, nil + case "INFO": + return INFO, nil + case "WARN": + return WARN, nil + case "ERROR": + return ERROR, nil + default: + return INFO, fmt.Errorf("invalid log level: %s", level) + } +} + +// 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, + } + + // 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) + } + + // 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 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) + } + + // Configure outputs + l.appOutput = appFile + if consoleOut { + l.appOutput = io.MultiWriter(appFile, os.Stdout) + } + + l.auditOutput = auditFile + l.secOutput = io.MultiWriter(securityFile, os.Stderr) // Security logs always go to stderr + + 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) 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), + ) +} + +// 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) 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)) + } + } + + 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)) + } + } + + 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)) + } + } + + 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 "" + } + return fmt.Sprintf(" - Fields: %v", fields) +}