diff --git a/core.go b/core.go index 9afef81..3846432 100644 --- a/core.go +++ b/core.go @@ -293,7 +293,6 @@ func (app *App) makeHTTPRouterHandlerFunc(h Controller, ms []Hook) httprouter.Ha for _, header := range ctx.Response.headers { w.Header().Add(header.key, header.val) } - logger.CloseLogsFile() var ct string if ctx.Response.overrideContentType != "" { ct = ctx.Response.overrideContentType @@ -334,7 +333,7 @@ func (n notFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) res := "{\"message\": \"Not Found\"}" loggr.Error("Not Found") - loggr.Error(debug.Stack()) + //loggr.Error(debug.Stack()) w.Header().Add(CONTENT_TYPE, CONTENT_TYPE_JSON) w.Write([]byte(res)) } @@ -344,7 +343,7 @@ func (n methodNotAllowed) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusMethodNotAllowed) res := "{\"message\": \"Method not allowed\"}" loggr.Error("Method not allowed") - loggr.Error(debug.Stack()) + //loggr.Error(debug.Stack()) w.Header().Add(CONTENT_TYPE, CONTENT_TYPE_JSON) w.Write([]byte(res)) } @@ -372,7 +371,7 @@ var panicHandler = func(w http.ResponseWriter, r *http.Request, e interface{}) { shrtMsg := fmt.Sprintf("%v", e) loggr.Error(shrtMsg) fmt.Println(shrtMsg) - loggr.Error(string(debug.Stack())) + //loggr.Error(string(debug.Stack())) var res string if env.GetVarOtherwiseDefault("APP_ENV", "local") == PRODUCTION { res = "{\"message\": \"internal error\"}" diff --git a/logger/logger.go b/logger/logger.go index 4746c9f..fd56eba 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -1,4 +1,4 @@ -// Copyright 2021 Harran Ali . All rights reserved. +// Copyright (c) 2026 Zeni Kim // Use of this source code is governed by MIT-style // license that can be found in the LICENSE file. @@ -8,16 +8,68 @@ import ( "io" "log" "os" + "strings" ) // logs file var logsFile *os.File +// ANSI color codes for terminal output +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorYellow = "\033[33m" + colorBlue = "\033[34m" + colorCyan = "\033[36m" +) + +// Level represents a log severity level. +type Level int + +const ( + DEBUG Level = iota + INFO + WARNING + ERROR +) + +// String returns the string representation of a log level. +func (l Level) String() string { + switch l { + case DEBUG: + return "debug" + case INFO: + return "info" + case WARNING: + return "warning" + case ERROR: + return "error" + default: + return "unknown" + } +} + +// coloredWriter wraps an io.Writer and replaces plain log prefixes with ANSI-colored ones +// in the output stream. The original bytes pass through unmodified to any additional writers. +type coloredWriter struct { + writer io.Writer + plain string + colored string +} + +func (w *coloredWriter) Write(p []byte) (n int, err error) { + // Replace the plain prefix with the colored version in the output + colored := strings.Replace(string(p), w.plain, w.colored, 1) + return w.writer.Write([]byte(colored)) +} + type Logger struct { + minLevel Level infoLogger *log.Logger warningLogger *log.Logger errorLogger *log.Logger debugLogger *log.Logger + file *os.File } var l *Logger @@ -40,9 +92,36 @@ func (f LogFileDriver) GetTarget() interface{} { return f.FilePath } +// levelPrefixes returns the plain and colored (with ANSI escapes) prefix strings for a given level. +func levelPrefixes(level Level) (plain, colored string) { + switch level { + case INFO: + return "info: ", colorBlue + "INFO" + colorReset + ": " + case DEBUG: + return "debug: ", colorCyan + "DEBUG" + colorReset + ": " + case WARNING: + return "warning: ", colorYellow + "WARNING" + colorReset + ": " + case ERROR: + return "error: ", colorRed + "ERROR" + colorReset + ": " + default: + return "info: ", "INFO: " + } +} + +// NewLogger creates a new Logger with the given driver and a minimum level of DEBUG (all levels logged). func NewLogger(driver LogsDriver) *Logger { + stdoutEnabled := os.Getenv("LOG_STDOUT_ENABLE") == "true" + return NewLoggerWithStdout(driver, DEBUG, stdoutEnabled) +} + +// NewLoggerWithStdout creates a new Logger with the given driver, minimum log level, +// and optionally writes to stdout in addition to the target file. +// When stdoutEnabled is true, log messages are written to both the target and stdout. +// ANSI color codes are applied to log prefixes in terminal output only. +func NewLoggerWithStdout(driver LogsDriver, minLevel Level, stdoutEnabled bool) *Logger { if driver.GetTarget() == nil { l = &Logger{ + minLevel: minLevel, infoLogger: log.New(io.Discard, "info: ", log.LstdFlags), warningLogger: log.New(io.Discard, "warning: ", log.LstdFlags), errorLogger: log.New(io.Discard, "error: ", log.LstdFlags), @@ -54,41 +133,103 @@ func NewLogger(driver LogsDriver) *Logger { if !ok { panic("something wrong with the file path") } - logsFile, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0644) + var err error + logsFile, err = os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0644) if err != nil { panic(err) } - l = &Logger{ - infoLogger: log.New(logsFile, "info: ", log.LstdFlags), - warningLogger: log.New(logsFile, "warning: ", log.LstdFlags), - errorLogger: log.New(logsFile, "error: ", log.LstdFlags), - debugLogger: log.New(logsFile, "debug: ", log.LstdFlags), + + if stdoutEnabled { + // Build each logger with a MultiWriter: the file gets the original plain output, + // stdout gets the output with ANSI-colored prefixes. + infoPlain, infoColored := levelPrefixes(INFO) + debugPlain, debugColored := levelPrefixes(DEBUG) + warnPlain, warnColored := levelPrefixes(WARNING) + errPlain, errColored := levelPrefixes(ERROR) + + l = &Logger{ + minLevel: minLevel, + infoLogger: log.New( + io.MultiWriter(logsFile, &coloredWriter{writer: os.Stdout, plain: infoPlain, colored: infoColored}), + infoPlain, log.LstdFlags, + ), + warningLogger: log.New( + io.MultiWriter(logsFile, &coloredWriter{writer: os.Stdout, plain: warnPlain, colored: warnColored}), + warnPlain, log.LstdFlags, + ), + errorLogger: log.New( + io.MultiWriter(logsFile, &coloredWriter{writer: os.Stdout, plain: errPlain, colored: errColored}), + errPlain, log.LstdFlags, + ), + debugLogger: log.New( + io.MultiWriter(logsFile, &coloredWriter{writer: os.Stdout, plain: debugPlain, colored: debugColored}), + debugPlain, log.LstdFlags, + ), + file: logsFile, + } + } else { + // No stdout: all output goes to file with plain prefixes + l = &Logger{ + minLevel: minLevel, + infoLogger: log.New(logsFile, "info: ", log.LstdFlags), + warningLogger: log.New(logsFile, "warning: ", log.LstdFlags), + errorLogger: log.New(logsFile, "error: ", log.LstdFlags), + debugLogger: log.New(logsFile, "debug: ", log.LstdFlags), + file: logsFile, + } } return l } +// NewLoggerWithLevel creates a new Logger with the given driver and minimum log level. +// Messages below the minimum level will be discarded. Stdout output is disabled by default. +func NewLoggerWithLevel(driver LogsDriver, minLevel Level) *Logger { + stdoutEnabled := os.Getenv("LOG_STDOUT_ENABLE") == "true" + return NewLoggerWithStdout(driver, minLevel, stdoutEnabled) +} + +// SetLevel sets the minimum log level for the logger. +// Messages below this level will be discarded. +func (l *Logger) SetLevel(level Level) { + l.minLevel = level +} + +// GetLevel returns the current minimum log level. +func (l *Logger) GetLevel() Level { + return l.minLevel +} + func ResolveLogger() *Logger { return l } func (l *Logger) Info(msg interface{}) { - l.infoLogger.Println(msg) + if l.minLevel <= INFO { + l.infoLogger.Println(msg) + } } func (l *Logger) Debug(msg interface{}) { - l.debugLogger.Println(msg) + if l.minLevel <= DEBUG { + l.debugLogger.Println(msg) + } } func (l *Logger) Warning(msg interface{}) { - l.warningLogger.Println(msg) + if l.minLevel <= WARNING { + l.warningLogger.Println(msg) + } } func (l *Logger) Error(msg interface{}) { - l.errorLogger.Println(msg) -} - -func CloseLogsFile() { - if logsFile != nil { - defer logsFile.Close() + if l.minLevel <= ERROR { + l.errorLogger.Println(msg) } } + +func (l *Logger) Close() error { + if l.file != nil { + return l.file.Close() + } + return nil +} diff --git a/logger/logger_test.go b/logger/logger_test.go index f82e8a1..46760ca 100644 --- a/logger/logger_test.go +++ b/logger/logger_test.go @@ -61,7 +61,7 @@ func TestInfo(t *testing.T) { t.Error("error testing info") } t.Cleanup(func() { - CloseLogsFile() + l.Close() }) } @@ -84,7 +84,7 @@ func TestWarning(t *testing.T) { t.Error("failed testing warning") } t.Cleanup(func() { - CloseLogsFile() + l.Close() }) } @@ -106,7 +106,7 @@ func TestDebug(t *testing.T) { t.Error("error testing debug") } t.Cleanup(func() { - CloseLogsFile() + l.Close() }) } @@ -128,6 +128,173 @@ func TestError(t *testing.T) { t.Error("failed testing error") } t.Cleanup(func() { - CloseLogsFile() + l.Close() }) } + +func TestLevelFiltering(t *testing.T) { + t.Run("NewLoggerWithLevel_DEBUG", func(t *testing.T) { + path := filepath.Join(t.TempDir(), uuid.NewString()) + l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, DEBUG) + l.Debug("debug-msg") + l.Info("info-msg") + l.Warning("warn-msg") + l.Error("err-msg") + l.Close() + + b, _ := os.ReadFile(path) + content := string(b) + if !strings.Contains(content, "debug-msg") { + t.Error("expected debug-msg to be logged at DEBUG level") + } + if !strings.Contains(content, "info-msg") { + t.Error("expected info-msg to be logged at DEBUG level") + } + if !strings.Contains(content, "warn-msg") { + t.Error("expected warn-msg to be logged at DEBUG level") + } + if !strings.Contains(content, "err-msg") { + t.Error("expected err-msg to be logged at DEBUG level") + } + }) + + t.Run("NewLoggerWithLevel_INFO", func(t *testing.T) { + path := filepath.Join(t.TempDir(), uuid.NewString()) + l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, INFO) + l.Debug("debug-msg") + l.Info("info-msg") + l.Warning("warn-msg") + l.Error("err-msg") + l.Close() + + b, _ := os.ReadFile(path) + content := string(b) + if strings.Contains(content, "debug-msg") { + t.Error("debug-msg should NOT be logged at INFO minimum level") + } + if !strings.Contains(content, "info-msg") { + t.Error("expected info-msg to be logged at INFO level") + } + if !strings.Contains(content, "warn-msg") { + t.Error("expected warn-msg to be logged at INFO level") + } + if !strings.Contains(content, "err-msg") { + t.Error("expected err-msg to be logged at INFO level") + } + }) + + t.Run("NewLoggerWithLevel_WARNING", func(t *testing.T) { + path := filepath.Join(t.TempDir(), uuid.NewString()) + l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, WARNING) + l.Debug("debug-msg") + l.Info("info-msg") + l.Warning("warn-msg") + l.Error("err-msg") + l.Close() + + b, _ := os.ReadFile(path) + content := string(b) + if strings.Contains(content, "debug-msg") { + t.Error("debug-msg should NOT be logged at WARNING minimum level") + } + if strings.Contains(content, "info-msg") { + t.Error("info-msg should NOT be logged at WARNING minimum level") + } + if !strings.Contains(content, "warn-msg") { + t.Error("expected warn-msg to be logged at WARNING level") + } + if !strings.Contains(content, "err-msg") { + t.Error("expected err-msg to be logged at WARNING level") + } + }) + + t.Run("NewLoggerWithLevel_ERROR", func(t *testing.T) { + path := filepath.Join(t.TempDir(), uuid.NewString()) + l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, ERROR) + l.Debug("debug-msg") + l.Info("info-msg") + l.Warning("warn-msg") + l.Error("err-msg") + l.Close() + + b, _ := os.ReadFile(path) + content := string(b) + if strings.Contains(content, "debug-msg") { + t.Error("debug-msg should NOT be logged at ERROR minimum level") + } + if strings.Contains(content, "info-msg") { + t.Error("info-msg should NOT be logged at ERROR minimum level") + } + if strings.Contains(content, "warn-msg") { + t.Error("warn-msg should NOT be logged at ERROR minimum level") + } + if !strings.Contains(content, "err-msg") { + t.Error("expected err-msg to be logged at ERROR level") + } + }) + + t.Run("SetLevel_dynamically", func(t *testing.T) { + path := filepath.Join(t.TempDir(), uuid.NewString()) + l := NewLogger(&LogFileDriver{FilePath: path}) + + // Default is DEBUG, so debug messages are logged + l.Debug("debug-msg-before") + l.SetLevel(WARNING) + l.Debug("debug-msg-after") + l.Info("info-msg-after") + l.Warning("warn-msg-after") + l.Error("err-msg-after") + l.Close() + + b, _ := os.ReadFile(path) + content := string(b) + if !strings.Contains(content, "debug-msg-before") { + t.Error("expected debug-msg-before to be logged before level change") + } + if strings.Contains(content, "debug-msg-after") { + t.Error("debug-msg-after should NOT be logged after setting level to WARNING") + } + if strings.Contains(content, "info-msg-after") { + t.Error("info-msg-after should NOT be logged after setting level to WARNING") + } + if !strings.Contains(content, "warn-msg-after") { + t.Error("expected warn-msg-after to be logged at WARNING level") + } + if !strings.Contains(content, "err-msg-after") { + t.Error("expected err-msg-after to be logged at WARNING level") + } + }) +} + +func TestLevelString(t *testing.T) { + tests := []struct { + level Level + want string + }{ + {DEBUG, "debug"}, + {INFO, "info"}, + {WARNING, "warning"}, + {ERROR, "error"}, + {Level(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.level.String(); got != tt.want { + t.Errorf("Level(%d).String() = %q, want %q", tt.level, got, tt.want) + } + } +} + +func TestGetLevel(t *testing.T) { + path := filepath.Join(t.TempDir(), uuid.NewString()) + l := NewLoggerWithLevel(&LogFileDriver{FilePath: path}, WARNING) + defer l.Close() + + if l.GetLevel() != WARNING { + t.Errorf("GetLevel() = %d, want %d", l.GetLevel(), WARNING) + } + + l.SetLevel(INFO) + if l.GetLevel() != INFO { + t.Errorf("GetLevel() after SetLevel(INFO) = %d, want %d", l.GetLevel(), INFO) + } +}