diff --git a/go.mod b/go.mod index 67f3590..41898fd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,13 @@ toolchain go1.23.7 require golang.org/x/crypto v0.36.0 +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/rs/zerolog v1.33.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect +) + require ( golang.org/x/sys v0.31.0 // indirect gopkg.in/yaml.v2 v2.4.0 diff --git a/go.sum b/go.sum index d05e730..f77848a 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,24 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..070ba76 --- /dev/null +++ b/log/log.go @@ -0,0 +1,150 @@ +package logpackage + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/rs/zerolog" + "gopkg.in/natefinch/lumberjack.v2" + "gopkg.in/yaml.v2" +) + +type LoggerConfig struct { + Level string `yaml:"level"` + FilePath string `yaml:"file_path"` + MaxSize int `yaml:"max_size"` + MaxBackups int `yaml:"max_backups"` + MaxAge int `yaml:"max_age"` + ContextKeys []string `yaml:"context_keys"` +} + +var ( + logger zerolog.Logger + cfg LoggerConfig + once sync.Once + + getConfigPath = func() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("failed to get runtime caller") + } + dir := filepath.Dir(file) + return filepath.Join(dir, "log.yaml"), nil + } +) + +func loadConfig() (LoggerConfig, error) { + var config LoggerConfig + + configPath, err := getConfigPath() + if err != nil { + return config, fmt.Errorf("error finding config path: %w", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return config, fmt.Errorf("failed to read config file: %w", err) + } + + err = yaml.Unmarshal(data, &config) + if err != nil { + return config, fmt.Errorf("failed to parse YAML: %w", err) + } + + return config, nil +} + +func InitLogger(configs ...LoggerConfig) { + once.Do(func() { + var err error + + if len(configs) > 0 { + cfg = configs[0] + } else { + cfg, err = loadConfig() + if err != nil { + fmt.Println("Logger initialization failed:", err) + return + } + } + + level, err := zerolog.ParseLevel(cfg.Level) + if err != nil { + level = zerolog.InfoLevel + } + + zerolog.SetGlobalLevel(level) + zerolog.TimeFieldFormat = time.RFC3339 + + logWriter := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339} + fileWriter := &lumberjack.Logger{ + Filename: cfg.FilePath, + MaxSize: cfg.MaxSize, + MaxBackups: cfg.MaxBackups, + MaxAge: cfg.MaxAge, + } + + multi := zerolog.MultiLevelWriter(logWriter, fileWriter) + logger = zerolog.New(multi).With().Timestamp().Logger() + }) +} + +func Debug(ctx context.Context, msg string) { + logEvent(ctx, logger.Debug(), msg) +} + +func Debugf(ctx context.Context, format string, v ...any) { + logEvent(ctx, logger.Debug(), fmt.Sprintf(format, v...)) +} + +func Info(ctx context.Context, msg string) { + logEvent(ctx, logger.Info(), msg) +} + +func Infof(ctx context.Context, format string, v ...any) { + logEvent(ctx, logger.Info(), fmt.Sprintf(format, v...)) +} + +func Warn(ctx context.Context, msg string) { + logEvent(ctx, logger.Warn(), msg) +} + +func Warnf(ctx context.Context, format string, v ...any) { + logEvent(ctx, logger.Warn(), fmt.Sprintf(format, v...)) +} + +func Error(ctx context.Context, err error, msg string) { + logEvent(ctx, logger.Error().Err(err), msg) +} + +func Errorf(ctx context.Context, err error, format string, v ...any) { + logEvent(ctx, logger.Error().Err(err), fmt.Sprintf(format, v...)) +} + +var ExitFunc = func(code int) { + os.Exit(code) +} + +func Fatal(ctx context.Context, msg string) { + logEvent(ctx, logger.Error(), msg) + ExitFunc(1) +} + +func Fatalf(ctx context.Context, format string, v ...any) { + logEvent(ctx, logger.Fatal(), fmt.Sprintf(format, v...)) + ExitFunc(1) +} + +func logEvent(ctx context.Context, event *zerolog.Event, msg string) { + for _, key := range cfg.ContextKeys { + if val, ok := ctx.Value(key).(string); ok && val != "" { + event.Str(key, val) + } + } + event.Msg(msg) +} diff --git a/log/log.yaml b/log/log.yaml new file mode 100644 index 0000000..ba00e7e --- /dev/null +++ b/log/log.yaml @@ -0,0 +1,12 @@ +level: info +destinations: + - type: stdout + - type: file + config: + path: logs/app.log + maxSize: "500" + maxBackups: "15" + maxAge: "30" +context_keys: + - requestId + - userId \ No newline at end of file diff --git a/log/logpackage_test.go b/log/logpackage_test.go new file mode 100644 index 0000000..e301b70 --- /dev/null +++ b/log/logpackage_test.go @@ -0,0 +1,391 @@ +package logpackage + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/rs/zerolog" +) + +func setupTest(t *testing.T) string { + once = sync.Once{} + + tempDir, err := os.MkdirTemp("", "logger-test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + + return tempDir +} + +func TestInitLoggerWithValidConfig(t *testing.T) { + tempDir := setupTest(t) + defer os.RemoveAll(tempDir) + + testLogPath := filepath.Join(tempDir, "test.log") + + InitLogger(LoggerConfig{ + Level: "debug", + FilePath: testLogPath, + MaxSize: 10, + MaxBackups: 3, + MaxAge: 7, + ContextKeys: []string{"request_id", "user_id"}, + }) + + if logger.GetLevel() == zerolog.Disabled { + t.Error("Logger was not initialized") + } + + ctx := context.WithValue(context.Background(), "request_id", "test-123") + Debug(ctx, "debug message") + Info(ctx, "info message") + Warn(ctx, "warning message") + Error(ctx, errors.New("test error"), "error message") + + if _, err := os.Stat(testLogPath); os.IsNotExist(err) { + t.Errorf("Log file was not created at %s", testLogPath) + } +} + +func TestInitLoggerWithInvalidLevel(t *testing.T) { + tempDir := setupTest(t) + defer os.RemoveAll(tempDir) + + testLogPath := filepath.Join(tempDir, "test.log") + + InitLogger(LoggerConfig{ + Level: "invalid_level", + FilePath: testLogPath, + MaxSize: 10, + MaxBackups: 3, + MaxAge: 7, + ContextKeys: []string{"request_id"}, + }) + + if logger.GetLevel() == zerolog.Disabled { + t.Error("Logger was not initialized") + } + + ctx := context.WithValue(context.Background(), "request_id", "test-123") + Info(ctx, "info message") + + if _, err := os.Stat(testLogPath); os.IsNotExist(err) { + t.Errorf("Log file was not created at %s", testLogPath) + } +} + +func TestLoadConfigFromFile(t *testing.T) { + tempDir := setupTest(t) + defer os.RemoveAll(tempDir) + + configPath := filepath.Join(tempDir, "log.yaml") + configContent := `level: debug +file_path: /tmp/test.log +max_size: 10 +max_backups: 3 +max_age: 7 +context_keys: + - request_id + - user_id` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + originalGetConfigPath := getConfigPath + defer func() { getConfigPath = originalGetConfigPath }() + getConfigPath = func() (string, error) { + return configPath, nil + } + + config, err := loadConfig() + if err != nil { + t.Errorf("loadConfig() error = %v", err) + } + + if config.Level != "debug" { + t.Errorf("Expected level 'debug', got '%s'", config.Level) + } + if config.FilePath != "/tmp/test.log" { + t.Errorf("Expected file_path '/tmp/test.log', got '%s'", config.FilePath) + } + if len(config.ContextKeys) != 2 { + t.Errorf("Expected 2 context keys, got %d", len(config.ContextKeys)) + } +} + +func TestLoadConfigNonExistent(t *testing.T) { + tempDir := setupTest(t) + defer os.RemoveAll(tempDir) + + configPath := filepath.Join(tempDir, "non-existent.yaml") + + originalGetConfigPath := getConfigPath + defer func() { getConfigPath = originalGetConfigPath }() + getConfigPath = func() (string, error) { + return configPath, nil + } + + _, err := loadConfig() + if err == nil { + t.Error("Expected error for non-existent config file, got nil") + } +} + +func TestLoadConfigInvalidYAML(t *testing.T) { + tempDir := setupTest(t) + defer os.RemoveAll(tempDir) + + configPath := filepath.Join(tempDir, "invalid.yaml") + configContent := `level: debug +file_path: /tmp/test.log +max_size: invalid +max_backups: 3` + + err := os.WriteFile(configPath, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + originalGetConfigPath := getConfigPath + defer func() { getConfigPath = originalGetConfigPath }() + getConfigPath = func() (string, error) { + return configPath, nil + } + + _, err = loadConfig() + if err == nil { + t.Error("Expected error for invalid YAML, got nil") + } +} + +func TestFatal(t *testing.T) { + tempDir := setupTest(t) + defer os.RemoveAll(tempDir) + + testLogPath := filepath.Join(tempDir, "fatal.log") + + originalExitFunc := ExitFunc + defer func() { ExitFunc = originalExitFunc }() + + var exitCalled bool + var exitCode int + ExitFunc = func(code int) { + exitCalled = true + exitCode = code + + } + + InitLogger(LoggerConfig{ + Level: "debug", + FilePath: testLogPath, + MaxSize: 10, + MaxBackups: 3, + MaxAge: 7, + ContextKeys: []string{"request_id"}, + }) + + ctx := context.WithValue(context.Background(), "request_id", "test-fatal") + Fatal(ctx, "fatal message") + + if !exitCalled { + t.Error("ExitFunc was not called") + } + if exitCode != 1 { + t.Errorf("Expected exit code 1, got %d", exitCode) + } + + if _, err := os.Stat(testLogPath); os.IsNotExist(err) { + t.Errorf("Log file was not created at %s", testLogPath) + } + + content, err := os.ReadFile(testLogPath) + if err != nil { + t.Errorf("Failed to read log file: %v", err) + } + if !strings.Contains(string(content), "fatal message") { + t.Error("Log file does not contain fatal message") + } +} + +func TestLoggingWithContext(t *testing.T) { + tempDir := setupTest(t) + defer os.RemoveAll(tempDir) + + testLogPath := filepath.Join(tempDir, "context.log") + + // Initialize logger with context keys + InitLogger(LoggerConfig{ + Level: "debug", + FilePath: testLogPath, + MaxSize: 10, + MaxBackups: 3, + MaxAge: 7, + ContextKeys: []string{"request_id", "user_id", "session_id"}, + }) + + ctx := context.Background() + ctx = context.WithValue(ctx, "request_id", "req-123") + ctx = context.WithValue(ctx, "user_id", "user-456") + Info(ctx, "message with context") + + if _, err := os.Stat(testLogPath); os.IsNotExist(err) { + t.Errorf("Log file was not created at %s", testLogPath) + } + + content, err := os.ReadFile(testLogPath) + if err != nil { + t.Errorf("Failed to read log file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "req-123") { + t.Error("Log file does not contain request_id") + } + if !strings.Contains(contentStr, "user-456") { + t.Error("Log file does not contain user_id") + } +} + +func TestFormattedLogging(t *testing.T) { + once = sync.Once{} + + tempDir, err := os.MkdirTemp("", "logger-format-test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + testLogPath := filepath.Join(tempDir, "formatted.log") + + InitLogger(LoggerConfig{ + Level: "debug", + FilePath: testLogPath, + MaxSize: 10, + MaxBackups: 3, + MaxAge: 7, + ContextKeys: []string{"request_id"}, + }) + + ctx := context.WithValue(context.Background(), "request_id", "format-test-123") + + testValues := []struct { + name string + number int + text string + expected string + }{ + { + name: "debug", + number: 42, + text: "formatted debug", + expected: "formatted debug message #42", + }, + { + name: "info", + number: 100, + text: "formatted info", + expected: "formatted info message #100", + }, + { + name: "warn", + number: 200, + text: "formatted warning", + expected: "formatted warning message #200", + }, + { + name: "error", + number: 500, + text: "formatted error", + expected: "formatted error message #500", + }, + } + + for _, tv := range testValues { + t.Run(tv.name+"f", func(t *testing.T) { + format := "%s message #%d" + + switch tv.name { + case "debug": + Debugf(ctx, format, tv.text, tv.number) + case "info": + Infof(ctx, format, tv.text, tv.number) + case "warn": + Warnf(ctx, format, tv.text, tv.number) + case "error": + testErr := errors.New("test error") + Errorf(ctx, testErr, format, tv.text, tv.number) + } + }) + } +} + +func TestLoggingWithNonStringContext(t *testing.T) { + tempDir := setupTest(t) + defer os.RemoveAll(tempDir) + + testLogPath := filepath.Join(tempDir, "non-string.log") + + InitLogger(LoggerConfig{ + Level: "debug", + FilePath: testLogPath, + MaxSize: 10, + MaxBackups: 3, + MaxAge: 7, + ContextKeys: []string{"request_id", "count"}, + }) + + ctx := context.Background() + ctx = context.WithValue(ctx, "request_id", "req-123") + ctx = context.WithValue(ctx, "count", 42) + + Info(ctx, "message with non-string context") + + if _, err := os.Stat(testLogPath); os.IsNotExist(err) { + t.Errorf("Log file was not created at %s", testLogPath) + } + + content, err := os.ReadFile(testLogPath) + if err != nil { + t.Errorf("Failed to read log file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "req-123") { + t.Error("Log file does not contain request_id") + } + +} + +func TestGetConfigPath(t *testing.T) { + path, err := getConfigPath() + if err != nil { + t.Errorf("getConfigPath() error = %v", err) + } + if path == "" { + t.Error("getConfigPath() returned empty path") + } +} + +func TestGetConfigPathError(t *testing.T) { + + originalGetConfigPath := getConfigPath + defer func() { getConfigPath = originalGetConfigPath }() + + expectedErr := errors.New("runtime caller error") + getConfigPath = func() (string, error) { + return "", expectedErr + } + + once = sync.Once{} + InitLogger() + ctx := context.Background() + Info(ctx, "info after config failure") +} \ No newline at end of file