From 7a04005875ab95b76df9ca38379339e0e9128f54 Mon Sep 17 00:00:00 2001 From: redpinecube Date: Mon, 13 Oct 2025 08:43:46 -0500 Subject: [PATCH] implemented multilogger Signed-off-by: redpinecube --- config/log.yaml | 2 +- src/api.go | 7 +- src/go.mod | 1 + src/go.sum | 2 + src/int_test.go | 7 +- src/log.go | 141 ------------ src/log_test.go | 1 - src/multilogger/README.md | 44 ++++ src/multilogger/log.go | 195 ++++++++++++++++ src/multilogger/log_test.go | 446 ++++++++++++++++++++++++++++++++++++ 10 files changed, 700 insertions(+), 146 deletions(-) delete mode 100644 src/log.go delete mode 100644 src/log_test.go create mode 100644 src/multilogger/README.md create mode 100644 src/multilogger/log.go create mode 100644 src/multilogger/log_test.go diff --git a/config/log.yaml b/config/log.yaml index c771915..367bea2 100644 --- a/config/log.yaml +++ b/config/log.yaml @@ -4,7 +4,7 @@ output: level: INFO - type: stdout level: INFO - directory: ./logs/ + directory: ../logs/ components: - app diff --git a/src/api.go b/src/api.go index ba29f2c..e2c3744 100644 --- a/src/api.go +++ b/src/api.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + log2 "mist/multilogger" "net/http" "os" "os/signal" @@ -114,7 +115,11 @@ func (a *App) Shutdown(ctx context.Context) error { } func main() { - log, err := createLogger("app") + cfg, err := log2.GetLogConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to get log config: %v\n", err) + } + log, err := log2.CreateLogger("app", &cfg) if err != nil { fmt.Fprintf(os.Stderr, "failed to create logger: %v\n", err) os.Exit(1) diff --git a/src/go.mod b/src/go.mod index 3e8eb50..097afa1 100644 --- a/src/go.mod +++ b/src/go.mod @@ -7,5 +7,6 @@ require github.com/redis/go-redis/v9 v9.10.0 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/src/go.sum b/src/go.sum index 30e2f56..0cdd507 100644 --- a/src/go.sum +++ b/src/go.sum @@ -9,5 +9,7 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/int_test.go b/src/int_test.go index 97e8553..ab364b6 100644 --- a/src/int_test.go +++ b/src/int_test.go @@ -3,6 +3,8 @@ package main import ( "context" "fmt" + "log/slog" + "mist/multilogger" "os" "os/signal" "sync" @@ -54,7 +56,8 @@ func TestIntegration(t *testing.T) { defer os.Unsetenv("ENV") redisAddr := "localhost:6379" - schedulerLog, err := createLogger("scheduler") + config, _ := multilogger.GetLogConfig() + schedulerLog, err := multilogger.CreateLogger("scheduler", &config) if err != nil { fmt.Fprintf(os.Stderr, "failed to create logger: %v\n", err) os.Exit(1) @@ -68,7 +71,7 @@ func TestIntegration(t *testing.T) { scheduler := NewScheduler(redisAddr, schedulerLog) defer scheduler.Close() - supervisorLog, err := createLogger("supervisor") + supervisorLog, err := multilogger.CreateLogger("supervisor", &config) if err != nil { fmt.Fprintf(os.Stderr, "failed to create logger: %v\n", err) os.Exit(1) diff --git a/src/log.go b/src/log.go deleted file mode 100644 index 5d5c359..0000000 --- a/src/log.go +++ /dev/null @@ -1,141 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "gopkg.in/yaml.v3" - "io" - "log/slog" - "os" - "path/filepath" -) - -const LogConfigFilePath = "../config/log.yaml" - -var levelMap = map[string]slog.Level{ - "DEBUG": slog.LevelDebug, - "INFO": slog.LevelInfo, - "WARN": slog.LevelWarn, - "ERROR": slog.LevelError, -} - -type MultiHandler struct { - subHandlers []slog.Handler -} - -type OutputType struct { - Type string `yaml:"type"` - Level string `yaml:"level"` -} - -type LogConfig struct { - Output struct { - Types []OutputType `yaml:"types"` - Directory string `yaml:"directory"` - } `yaml:"output"` - Components []string `yaml:"components"` -} - -func NewMultiHandler(writerLevels map[io.Writer]slog.Level) *MultiHandler { - var handlers []slog.Handler - - for writer, level := range writerLevels { - handlers = append(handlers, slog.NewJSONHandler(writer, &slog.HandlerOptions{Level: level})) - } - - m := MultiHandler{ - subHandlers: handlers, - } - return &m -} - -func (h *MultiHandler) Enabled(ctx context.Context, level slog.Level) bool { - for _, handler := range h.subHandlers { - if handler.Enabled(ctx, level) { - return true - } - } - return false - -} - -func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return h } -func (h *MultiHandler) WithGroup(name string) slog.Handler { return h } -func (h *MultiHandler) Handle(ctx context.Context, record slog.Record) error { - var err error - for _, handler := range h.subHandlers { - if handler.Enabled(ctx, record.Level) { - if out := handler.Handle(ctx, record); out != nil { - err = out - } - } - } - return err -} - -func getLogConfig(file string) (LogConfig, error) { - var config LogConfig - - configFile, err := os.ReadFile(file) - if err != nil { - return config, err - } - - err = yaml.Unmarshal(configFile, &config) - if err != nil { - return config, err - } - - return config, nil -} - -func fallbackLogger(component string) *slog.Logger { - return slog.New( - slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}), - ).With("component", component) -} - -func createLogger(component string) (*slog.Logger, error) { - logConfig, err := getLogConfig(LogConfigFilePath) - if err != nil { - fallback := fallbackLogger(component) - fallback.Warn("using fallback logger") - return fallback, fmt.Errorf("failed to load log config: %w", err) - } - - writerLevels := make(map[io.Writer]slog.Level) - for _, t := range logConfig.Output.Types { - switch t.Type { - case "stdout": - writerLevels[os.Stdout] = levelMap[t.Level] - case "file": - directory := logConfig.Output.Directory - - if err := os.MkdirAll(directory, 0755); err != nil { - fallback := fallbackLogger(component) - fallback.Warn("using fallback logger") - return fallback, fmt.Errorf("failed to create log directory: %w", err) - } - - filePath := filepath.Join(directory, component+".log") - file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - fallback := fallbackLogger(component) - fallback.Warn("using fallback logger") - return fallback, fmt.Errorf("failed to open log file %q: %w", filePath, err) - } - writerLevels[file] = levelMap[t.Level] - } - } - - if len(writerLevels) == 0 { - fallback := fallbackLogger(component) - fallback.Warn("using fallback logger") - return fallback, errors.New("no valid log outputs configured") - } - - handler := NewMultiHandler(writerLevels) - logger := slog.New(handler).With("component", component) - return logger, nil -} diff --git a/src/log_test.go b/src/log_test.go deleted file mode 100644 index 06ab7d0..0000000 --- a/src/log_test.go +++ /dev/null @@ -1 +0,0 @@ -package main diff --git a/src/multilogger/README.md b/src/multilogger/README.md new file mode 100644 index 0000000..02dc76c --- /dev/null +++ b/src/multilogger/README.md @@ -0,0 +1,44 @@ +# multilogger + +`multilogger` lets you log to multiple io.Writer instances at once, with JSON structured logs, file rotation, and flexible metadata. + +--- + +## Overview + + +Multilogger allows you to write logs to multiple io.Writer targets (e.g. stdout, files, etc.) simultaneously, each with its own log level. +It’s ideal for tracking runtime activity across components such as schedulers, supervisors, and the main application. +If the configuration fails or a destination cannot be initialized, multilogger automatically falls back to a stderr logger. +File rotation is handled using [lumberjack](https://github.com/natefinch/lumberjack). Environment variables are able to override the log levels configured in the log.yaml file. + +--- + +## Example Usage +```go +// Create Multilogger +config := multilogger.GetLogConfig() +logger, _ := multilogger.CreateLogger("app", &config) +logger.Info("starting app") +``` + +```go +// Set Global Log Level +os.Setenv("LOG_LEVEL", "DEBUG") +``` + +```go +// Configure Log Levels By io.Writer +os.Setenv("FILE_LOG_LEVEL", "DEBUG") +os.Setenv("STDOUT_LOG_LEVEL", "INFO") +``` + +```go +// Add Metadata +config := multilogger.GetLogConfig() +logger, _ := multilogger.CreateLogger("app", &config) +logger = logger.WithGroup("jobInfo") +logger.Info("job started", "job_id", "abc123") +``` + +--- diff --git a/src/multilogger/log.go b/src/multilogger/log.go new file mode 100644 index 0000000..03a97d3 --- /dev/null +++ b/src/multilogger/log.go @@ -0,0 +1,195 @@ +package multilogger + +import ( + "context" + "errors" + "fmt" + "gopkg.in/natefinch/lumberjack.v2" + "gopkg.in/yaml.v3" + "io" + "log/slog" + "os" + "path/filepath" + "strings" +) + +const LogConfigFilePath = "../config/log.yaml" + +var levelMap = map[string]slog.Level{ + "DEBUG": slog.LevelDebug, + "INFO": slog.LevelInfo, + "WARN": slog.LevelWarn, + "ERROR": slog.LevelError, +} + +type MultiHandler struct { + subHandlers []slog.Handler +} + +type OutputType struct { + Type string `yaml:"type"` + Level string `yaml:"level"` +} + +type LogConfig struct { + Output struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + } `yaml:"output"` + Components []string `yaml:"components"` +} + +func NewMultiHandler(writerLevels map[io.Writer]slog.Level) *MultiHandler { + var handlers []slog.Handler + + for writer, level := range writerLevels { + handlers = append(handlers, slog.NewJSONHandler(writer, &slog.HandlerOptions{Level: level})) + } + + m := MultiHandler{ + subHandlers: handlers, + } + return &m +} + +func (h *MultiHandler) Enabled(ctx context.Context, level slog.Level) bool { + for _, handler := range h.subHandlers { + if handler.Enabled(ctx, level) { + return true + } + } + return false + +} + +func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + newSubHandlers := make([]slog.Handler, len(h.subHandlers)) + for i, sub := range h.subHandlers { + newSubHandlers[i] = sub.WithAttrs(attrs) + } + return &MultiHandler{subHandlers: newSubHandlers} +} + +func (h *MultiHandler) WithGroup(name string) slog.Handler { + newSubHandlers := make([]slog.Handler, len(h.subHandlers)) + for i, sub := range h.subHandlers { + newSubHandlers[i] = sub.WithGroup(name) + } + return &MultiHandler{subHandlers: newSubHandlers} +} + +func (h *MultiHandler) Handle(ctx context.Context, record slog.Record) error { + var err error + for _, handler := range h.subHandlers { + if handler.Enabled(ctx, record.Level) { + if out := handler.Handle(ctx, record); out != nil { + err = out + } + } + } + return err +} + +func GetLogConfig() (LogConfig, error) { + var config LogConfig + + configFile, err := os.ReadFile(LogConfigFilePath) + if err != nil { + return config, err + } + + err = yaml.Unmarshal(configFile, &config) + if err != nil { + return config, err + } + + return config, nil +} + +func FallbackLogger(component string) *slog.Logger { + return slog.New( + slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}), + ).With("component", component) +} + +func CreateLogger(component string, config *LogConfig) (*slog.Logger, error) { + if err := OverrideYAMLConfig(config); err != nil { + fallback := FallbackLogger(component) + fallback.Warn("using fallback logger due to invalid env override") + return fallback, fmt.Errorf("failed to override YAML config: %w", err) + } + + writerLevels := make(map[io.Writer]slog.Level) + for _, t := range config.Output.Types { + lvl, ok := levelMap[t.Level] + if !ok { + fallback := FallbackLogger(component) + fallback.Warn("using fallback logger due to invalid log level") + return fallback, fmt.Errorf("invalid log level: %q", t.Level) + } + + switch t.Type { + case "stdout": + writerLevels[os.Stdout] = lvl + case "file": + directory := config.Output.Directory + + if err := os.MkdirAll(directory, 0755); err != nil { + fallback := FallbackLogger(component) + fallback.Warn("using fallback logger") + return fallback, fmt.Errorf("failed to create multilogger directory: %w", err) + } + + filePath := filepath.Join(directory, component+".log") + rotatingFileWriter := &lumberjack.Logger{ + Filename: filePath, + MaxSize: 10, + MaxBackups: 3, + MaxAge: 28, + Compress: true, + } + + writerLevels[rotatingFileWriter] = lvl + + default: + fallback := FallbackLogger(component) + fallback.Warn("using fallback logger due to unsupported output type", "type", t.Type) + return fallback, fmt.Errorf("unsupported output type: %q", t.Type) + } + } + + if len(writerLevels) == 0 { + fallback := FallbackLogger(component) + fallback.Warn("using fallback logger") + return fallback, errors.New("no valid multilogger outputs configured") + } + + handler := NewMultiHandler(writerLevels) + logger := slog.New(handler).With("component", component) + slog.Info("logger created successfully", "component", component, "outputs", len(writerLevels)) + return logger, nil +} + +func OverrideYAMLConfig(config *LogConfig) error { + if global := os.Getenv("LOG_LEVEL"); global != "" { + if _, ok := levelMap[global]; ok { + for i := range config.Output.Types { + config.Output.Types[i].Level = global + } + } else { + return fmt.Errorf("invalid global LOG_LEVEL: %q", global) + } + } + + for i, output := range config.Output.Types { + env := strings.ToUpper(output.Type) + "_LOG_LEVEL" + if lvl := strings.TrimSpace(os.Getenv(env)); lvl != "" { + if _, ok := levelMap[lvl]; ok { + config.Output.Types[i].Level = lvl + } else { + return fmt.Errorf("invalid multilogger level %q for %s", lvl, env) + } + } + } + return nil +} diff --git a/src/multilogger/log_test.go b/src/multilogger/log_test.go new file mode 100644 index 0000000..8c495f9 --- /dev/null +++ b/src/multilogger/log_test.go @@ -0,0 +1,446 @@ +package multilogger + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func getLogMessages(text *bytes.Buffer) []string { + scanner := bufio.NewScanner(text) + msgs := []string{} + + for scanner.Scan() { + obj := map[string]interface{}{} + line := scanner.Text() + json.Unmarshal([]byte(line), &obj) + msgs = append(msgs, obj["msg"].(string)) + } + return msgs +} + +func TestMultiHandler(t *testing.T) { + t.Run("test multiple writers with different log levels", func(t *testing.T) { + writer1 := &bytes.Buffer{} + writer2 := &bytes.Buffer{} + writer3 := &bytes.Buffer{} + + writerLevels := map[io.Writer]slog.Level{ + writer1: slog.LevelInfo, + writer2: slog.LevelDebug, + writer3: slog.LevelError, + } + + handler := NewMultiHandler(writerLevels) + logger := slog.New(handler) + + logger.Debug("debug") + logger.Info("info") + logger.Warn("warn") + logger.Error("error") + + expectedWriter1 := []string{"info", "warn", "error"} + expectedWriter2 := []string{"debug", "info", "warn", "error"} + expectedWriter3 := []string{"error"} + + if !reflect.DeepEqual(getLogMessages(writer1), expectedWriter1) { + t.Errorf("writer1: got %v, want %v", getLogMessages(writer1), expectedWriter1) + } + if !reflect.DeepEqual(getLogMessages(writer2), expectedWriter2) { + t.Errorf("writer2: got %v, want %v", getLogMessages(writer2), expectedWriter2) + } + if !reflect.DeepEqual(getLogMessages(writer3), expectedWriter3) { + t.Errorf("writer3: got %v, want %v", getLogMessages(writer3), expectedWriter3) + } + }) + + t.Run("test that WithGroup works with multiple handlers ", func(t *testing.T) { + writer1 := &bytes.Buffer{} + writer2 := &bytes.Buffer{} + + writerLevels := map[io.Writer]slog.Level{ + writer1: slog.LevelInfo, + writer2: slog.LevelDebug, + } + + handler := NewMultiHandler(writerLevels) + logger := slog.New(handler) + + groupLogger := logger.WithGroup("request") + groupLogger.Info("group test", "id", 123) + + obj1 := map[string]interface{}{} + obj2 := map[string]interface{}{} + json.Unmarshal(writer1.Bytes(), &obj1) + json.Unmarshal(writer2.Bytes(), &obj2) + + requestGroup1, ok := obj1["request"].(map[string]interface{}) + if !ok { + t.Fatal("expected 'request' to be a map") + } + + requestGroup2, ok := obj2["request"].(map[string]interface{}) + if !ok { + t.Fatal("expected 'request' to be a map") + } + + if requestGroup1["id"] != float64(123) { + t.Errorf("expected id 123, got %v", requestGroup1["id"]) + } + + if requestGroup2["id"] != float64(123) { + t.Errorf("expected id 123, got %v", requestGroup2["id"]) + } + + }) + + t.Run("test that WithGroup works where a sub handler with a higher level should not recieve a group", func(t *testing.T) { + writer1 := &bytes.Buffer{} + writer2 := &bytes.Buffer{} + + writerLevels := map[io.Writer]slog.Level{ + writer1: slog.LevelInfo, + writer2: slog.LevelError, + } + + handler := NewMultiHandler(writerLevels) + logger := slog.New(handler) + + groupLogger := logger.WithGroup("request") + groupLogger.Info("group test", "id", 123) + + obj1 := map[string]interface{}{} + obj2 := map[string]interface{}{} + json.Unmarshal(writer1.Bytes(), &obj1) + json.Unmarshal(writer2.Bytes(), &obj2) + + requestGroup1, ok := obj1["request"].(map[string]interface{}) + if !ok { + t.Fatal("expected 'request' to be a map") + } + + if _, ok := obj2["request"]; ok { + t.Fatal("did not expect 'request' group to exist for this writer") + } + + if requestGroup1["id"] != float64(123) { + t.Errorf("expected id 123, got %v", requestGroup1["id"]) + } + + }) + + t.Run("test WithAttrs with multiple sub handlers", func(t *testing.T) { + writer1 := &bytes.Buffer{} + writer2 := &bytes.Buffer{} + + writerLevels := map[io.Writer]slog.Level{ + writer1: slog.LevelInfo, + writer2: slog.LevelDebug, + } + + handler := NewMultiHandler(writerLevels) + newHandler := handler.WithAttrs([]slog.Attr{ + slog.String("exampleKey1", "testName"), + slog.Int("exampleKey2", 123), + }) + + logger := slog.New(newHandler) + logger.Info("info message") + + obj1 := map[string]interface{}{} + obj2 := map[string]interface{}{} + json.Unmarshal(writer1.Bytes(), &obj1) + json.Unmarshal(writer2.Bytes(), &obj2) + + if obj1["exampleKey1"] != "testName" { + t.Fatal("expected 'exampleKey1' to be 'testName'") + } + + if int(obj1["exampleKey2"].(float64)) != 123 { + t.Fatal("expected 'exampleKey2' to be '123'") + } + + if obj2["exampleKey1"] != "testName" { + t.Fatal("expected 'exampleKey1' to be 'testName'") + } + + if int(obj2["exampleKey2"].(float64)) != 123 { + t.Fatal("expected 'exampleKey2' to be '123'") + } + + }) +} + +func TestOverrideYAMLConfig(t *testing.T) { + t.Run("test with no env variables", func(t *testing.T) { + os.Clearenv() + config := &LogConfig{ + Output: struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + }{ + Directory: t.TempDir(), + Types: []OutputType{ + {Type: "stdout", Level: "INFO"}, + {Type: "file", Level: "DEBUG"}, + }, + }, + Components: []string{"app"}, + } + + err := OverrideYAMLConfig(config) + if err != nil { + t.Fatal(err) + } + + if config.Output.Types[0].Level != "INFO" { + t.Fatal("expected 'stdout' to be 'INFO'") + } + + if config.Output.Types[1].Level != "DEBUG" { + t.Fatal("expected 'file' to be 'DEBUG'") + } + + }) + t.Run("test global env variable", func(t *testing.T) { + os.Clearenv() + config := &LogConfig{ + Output: struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + }{ + Directory: t.TempDir(), + Types: []OutputType{ + {Type: "stdout", Level: "WARN"}, + {Type: "file", Level: "DEBUG"}, + }, + }, + Components: []string{"app"}, + } + + os.Setenv("LOG_LEVEL", "INFO") + + err := OverrideYAMLConfig(config) + if err != nil { + t.Fatal(err) + } + + if config.Output.Types[0].Level != "INFO" { + t.Fatal("expected 'stdout' to be 'INFO'") + } + + if config.Output.Types[1].Level != "INFO" { + t.Fatal("expected 'file' to be 'INFO'") + } + + }) + + t.Run("test env variables for different handlers", func(t *testing.T) { + os.Clearenv() + config := &LogConfig{ + Output: struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + }{ + Directory: t.TempDir(), + Types: []OutputType{ + {Type: "stdout", Level: "WARN"}, + {Type: "file", Level: "DEBUG"}, + }, + }, + Components: []string{"app"}, + } + + os.Setenv("STDOUT_LOG_LEVEL", "DEBUG") + os.Setenv("FILE_LOG_LEVEL", "INFO") + + err := OverrideYAMLConfig(config) + if err != nil { + t.Fatal(err) + } + + if config.Output.Types[0].Level != "DEBUG" { + t.Fatal("expected 'stdout' to be 'DEBUG'") + } + + if config.Output.Types[1].Level != "INFO" { + t.Fatal("expected 'file' to be 'INFO'") + } + + }) + +} + +func TestFallbackLogger(t *testing.T) { + t.Run("invalid log level triggers fallback", func(t *testing.T) { + os.Clearenv() + config := &LogConfig{ + Output: struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + }{ + Directory: t.TempDir(), + Types: []OutputType{{Type: "stdout", Level: "INVALID"}}, + }, + Components: []string{"app"}, + } + + logger, err := CreateLogger("app", config) + if err == nil { + t.Fatal("expected error due to invalid log level") + } + if logger == nil { + t.Fatal("expected fallback logger to be returned") + } + + }) + + t.Run("uncreatable directory triggers fallback", func(t *testing.T) { + os.Clearenv() + config := &LogConfig{ + Output: struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + }{ + Directory: "/root/invaliddir", + Types: []OutputType{{Type: "file", Level: "INFO"}}, + }, + Components: []string{"app"}, + } + + logger, err := CreateLogger("app", config) + if err == nil { + t.Fatal("expected error due to directory creation failure") + } + if logger == nil { + t.Fatal("expected fallback logger to be returned") + } + + }) + + t.Run("no outputs configured triggers fallback", func(t *testing.T) { + os.Clearenv() + config := &LogConfig{ + Output: struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + }{ + Directory: t.TempDir(), + Types: []OutputType{}, + }, + Components: []string{"app"}, + } + + logger, err := CreateLogger("app", config) + if err == nil { + t.Fatal("expected error due to no outputs configured") + } + if logger == nil { + t.Fatal("expected fallback logger to be returned") + } + + }) + + t.Run("unsupported output type triggers fallback", func(t *testing.T) { + os.Clearenv() + config := &LogConfig{ + Output: struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + }{ + Directory: t.TempDir(), + Types: []OutputType{{Type: "network", Level: "INFO"}}, + }, + Components: []string{"app"}, + } + + logger, err := CreateLogger("app", config) + if err == nil { + t.Fatal("expected error due to unsupported output type") + } + if logger == nil { + t.Fatal("expected fallback logger to be returned") + } + }) + + t.Run("missing directory for file output triggers fallback", func(t *testing.T) { + os.Clearenv() + config := &LogConfig{ + Output: struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + }{ + Directory: "", + Types: []OutputType{{Type: "file", Level: "INFO"}}, + }, + Components: []string{"app"}, + } + + logger, err := CreateLogger("app", config) + if err == nil { + t.Fatal("expected error due to missing directory") + } + if logger == nil { + t.Fatal("expected fallback logger to be returned") + } + }) + +} + +func TestCreateLogger(t *testing.T) { + t.Run("valid config creates multi-handler successfully", func(t *testing.T) { + os.Clearenv() + tmpDir := t.TempDir() + config := &LogConfig{ + Output: struct { + Types []OutputType `yaml:"types"` + Directory string `yaml:"directory"` + }{ + Directory: tmpDir, + Types: []OutputType{ + {Type: "stdout", Level: "INFO"}, + {Type: "file", Level: "DEBUG"}, + }, + }, + Components: []string{"app"}, + } + + logger, err := CreateLogger("app", config) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if logger == nil { + t.Fatal("expected logger to be created") + } + + testMsg := "this should go into the file" + logger.Info(testMsg) + + logFilePath := filepath.Join(tmpDir, "app.log") + time.Sleep(50 * time.Millisecond) + + if _, err := os.Stat(logFilePath); err != nil { + if os.IsNotExist(err) { + t.Fatalf("expected log file to exist at %s", logFilePath) + } + t.Fatalf("failed to stat log file: %v", err) + } + + content, err := os.ReadFile(logFilePath) + if err != nil { + t.Fatalf("failed to read log file: %v", err) + } + if !strings.Contains(string(content), testMsg) { + t.Fatalf("expected log file to contain %q, got:\n%s", testMsg, string(content)) + } + + }) +}