From 095d04449c21dcd663d11869a249d02c52a4a0d7 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Sat, 27 Dec 2025 10:00:35 +0000 Subject: [PATCH] Copy the structured logging code from my other project into here as example --- example/structured_logging/README.md | 56 ++++++++++++- example/structured_logging/env.example | 6 ++ example/structured_logging/logger/config.go | 71 +++++++++++++++++ example/structured_logging/logger/logger.go | 88 +++++++++++++++++++++ example/structured_logging/main.go | 28 +++---- 5 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 example/structured_logging/env.example create mode 100644 example/structured_logging/logger/config.go create mode 100644 example/structured_logging/logger/logger.go diff --git a/example/structured_logging/README.md b/example/structured_logging/README.md index cceedf5..50bda14 100644 --- a/example/structured_logging/README.md +++ b/example/structured_logging/README.md @@ -1,8 +1,56 @@ # Structured Logging -This demonstration shows how to use structured logging. -It shows the provided `LogError` function as well as how to iterate over the error list -and log manually. +This demonstration shows how I am currently handling logging in my wide-game-bot project. +There's a little Claude in there, and I'm tidying as go. I need to work on how I'll +log with request ID. (I need to look into how slog's LogContext works. It should be a case +of picking up the request ID from the current GO context.Context and including it in the +log messages) + +My log component here reads its own initialization rather than relying on the central +configuration loader. This allows me to initialize logging quickly. I set up some sensible +fallback defaults. There is an issue in GoConfig [#29](https://github.com/m0rjc/goconfig/issues/29) to better support this use case by having +GoConfig use the declared defaults if a user supplied value is invalid. For now, the defaulting +is provided in code. See `logger/config.go`, copied below: + +```go +func loadConfig() (*LogConfig, error) { + // Defaulting is provided here. If the user input is invalid, then GoConfig will not + // overwrite these values. Issue 29 would have GoConfig use the declared defaults instead, + // so avoiding the need for this defaulting code. + config := LogConfig{ + Level: slog.LevelInfo, + Format: LogFormatText, + } + + // The two custom types are registered here, so only applying them for this request. + // This prevents the logging package affecting the wider application. + configError := goconfig.Load(context.Background(), &config, + goconfig.WithCustomType(typeLogFormat), + goconfig.WithCustomType(typeLogLevel)) + + return &config, configError +} +``` + +The code was written to support logging to a file using Luberjack. I'm running in Kubernetes +at the moment, so my dev system logs to stdout to be picked up by the Kubernetes logging +stack. The Lumberjack code is commented out here to allow this example to compile without +the Luberjack dependency. This code is expected to error, so for that reason will exit 0. This will allow the -GitHub pull request action to pass. \ No newline at end of file +GitHub pull request action to pass. + +# Types for Log Level and Log Format + +This example uses GoConfig custom types for Log Level and Log Format. These are defined in +the logger package. To see this fail, try + +```bash +export LOG_LEVEL=INCORRECT +go run . +``` + +I've not raised an issue to add Log Level to GoConfig yet. Is it better that GoConfig supports +so many types? Or should I leave it to end users to provide their own as needed? The cost of +providing this in GoConfig is small, but it adds to the amount of hidden knowledge in the library. +This can be easily added if there is demand. \ No newline at end of file diff --git a/example/structured_logging/env.example b/example/structured_logging/env.example new file mode 100644 index 0000000..9a54d3f --- /dev/null +++ b/example/structured_logging/env.example @@ -0,0 +1,6 @@ +# These settings will fail constraints so causing logs to be produced. +DB_PORT=80 +DB_USER=admin +DB_PASS=short +DB_NAME=production +DB_TIMEOUT=500 diff --git a/example/structured_logging/logger/config.go b/example/structured_logging/logger/config.go new file mode 100644 index 0000000..5b6ee37 --- /dev/null +++ b/example/structured_logging/logger/config.go @@ -0,0 +1,71 @@ +package logger + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/m0rjc/goconfig" +) + +type LogFormat string + +var ( + LogFormatText LogFormat = "text" + LogFormatJSON LogFormat = "json" +) + +// LogConfig holds logging configuration +type LogConfig struct { + Level slog.Level `key:"LOG_LEVEL" default:"INFO"` + Format LogFormat `key:"LOG_FORMAT" default:"text"` + FilePath string `key:"LOG_FILE"` + MaxSize int `key:"LOG_MAX_SIZE" default:"100" min:"1"` + MaxBackups int `key:"LOG_MAX_BACKUPS" default:"0" min:"0"` + MaxAge int `key:"LOG_MAX_AGE" default:"0" min:"0"` + Compress bool `key:"LOG_COMPRESS" default:"false"` +} + +var ErrUnableToLoadConfig = fmt.Errorf("unable to load logger configuration") + +var typeLogFormat = goconfig.TransformCustomType[string, LogFormat](goconfig.DefaultStringType[string](), + func(rawValue string) (LogFormat, error) { + switch strings.ToLower(rawValue) { + case "text": + return LogFormatText, nil + case "json": + return LogFormatJSON, nil + default: + return LogFormatText, fmt.Errorf("invalid log format: %s", rawValue) + } + }) + +var typeLogLevel = goconfig.TransformCustomType[string, slog.Level](goconfig.DefaultStringType[string](), + func(rawValue string) (slog.Level, error) { + switch strings.ToUpper(rawValue) { + case "DEBUG": + return slog.LevelDebug, nil + case "INFO": + return slog.LevelInfo, nil + case "WARN", "WARNING": + return slog.LevelWarn, nil + case "ERROR": + return slog.LevelError, nil + default: + return slog.LevelInfo, fmt.Errorf("invalid log level: %s", rawValue) + } + }) + +func loadConfig() (*LogConfig, error) { + config := LogConfig{ + Level: slog.LevelInfo, + Format: LogFormatText, + } + + configError := goconfig.Load(context.Background(), &config, + goconfig.WithCustomType(typeLogFormat), + goconfig.WithCustomType(typeLogLevel)) + + return &config, configError +} diff --git a/example/structured_logging/logger/logger.go b/example/structured_logging/logger/logger.go new file mode 100644 index 0000000..0347700 --- /dev/null +++ b/example/structured_logging/logger/logger.go @@ -0,0 +1,88 @@ +package logger + +import ( + "io" + "log/slog" + "os" + + "github.com/m0rjc/goconfig" +) + +// logWriter holds the opened log writer (file or lumberjack) +var logWriter io.WriteCloser + +// Init initializes the global logger based on configuration. +// The configuration may be corrupt if we were unable to load config, so try our best. +func Init() error { + config, configError := loadConfig() + + // Try to initialize as best we can, even if we have an error. + // This allows us to log the error before returning. + loggerError := initialise(config) + + if configError != nil { + goconfig.LogError(slog.Default(), configError) + return ErrUnableToLoadConfig + } + if loggerError != nil { + slog.Error("logger_initialisation_failed", "error", loggerError) + return loggerError + } + return nil +} + +func initialise(config *LogConfig) error { + // Create handler options + opts := &slog.HandlerOptions{ + Level: config.Level, + } + + // Determine output destination + var output io.Writer + if config.FilePath != "" { + // Use lumberjack for log rotation. Commented out so that this code can build in the goconfig repo. + //lj := &lumberjack.Logger{ + // Filename: config.FilePath, + // MaxSize: config.MaxSize, // MB + // MaxBackups: config.MaxBackups, // Number of backups + // MaxAge: config.MaxAge, // Days + // Compress: config.Compress, // Compress old files + //} + //logWriter = lj + //output = lj + } else { + // Log to stdout + output = os.Stdout + } + + // Choose handler based on format + var handler slog.Handler + switch config.Format { + case LogFormatJSON: + handler = slog.NewJSONHandler(output, opts) + case LogFormatText: + handler = slog.NewTextHandler(output, opts) + default: + handler = slog.NewTextHandler(output, opts) + } + + slog.SetDefault(slog.New(handler)) + + // If we opened a file then log the fact + if config.FilePath != "" { + slog.Info("logfile_initialised [FAKE - SEE DEMO CODE]", + "file", config.FilePath, + "max_size", config.MaxSize, + "max_backups", config.MaxBackups, + "max_age", config.MaxAge, + "compress", config.Compress) + } + return nil +} + +// Close closes the log writer if one was opened +func Close() { + if logWriter != nil { + logWriter.Close() + } +} diff --git a/example/structured_logging/main.go b/example/structured_logging/main.go index 78071f8..d050bcc 100644 --- a/example/structured_logging/main.go +++ b/example/structured_logging/main.go @@ -8,31 +8,29 @@ import ( "github.com/m0rjc/goconfig" "github.com/m0rjc/goconfig/example/structured_logging/config" + "github.com/m0rjc/goconfig/example/structured_logging/logger" ) func main() { // 1. Set up a structured logger - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - slog.SetDefault(logger) - - // 2. Set up environment that will fail constraints - // DB_HOST is missing (mandatory) - os.Setenv("DB_PORT", "80") // fails min:1024 - os.Setenv("DB_USER", "admin") // valid - os.Setenv("DB_PASS", "short") // fails pattern: ^.{8,}$ - os.Setenv("DB_NAME", "production") // valid - os.Setenv("DB_TIMEOUT", "500") // fails max:300 + err := logger.Init() + defer logger.Close() + if err != nil { + slog.Error("Failed to initialise structured logger", "error", err) + // Exit(0) because the point of this demo is to show logging. The build pipeline requires a 0 exit code. + os.Exit(0) + } ctx := context.Background() cfg, err := config.Load(ctx) if err != nil { - logger.Info("The following log is generated by the provided gocondfig.LogError fucntion") - goconfig.LogError(logger, err, goconfig.WithLogMessage("config_error")) + slog.Info("The following log is generated by the provided gocondfig.LogError fucntion") + goconfig.LogError(slog.Default(), err, goconfig.WithLogMessage("config_error")) var configErrors *goconfig.ConfigErrors if errors.As(err, &configErrors) { - logger.Info("The following log is generated by iterating over the errors manually") + slog.Info("The following log is generated by iterating over the errors manually") // 3. Log errors to structured log for _, e := range configErrors.Errors { slog.Error("Configuration error", @@ -41,10 +39,10 @@ func main() { ) } // The purpose of this demo is to show logging. Exit(0) for an expected error. - logger.Info("This error was expected. The program will exit successfully to allow build pipelines to pass.") + slog.Info("This error was expected. The program will exit successfully to allow build pipelines to pass.") os.Exit(0) } else { - logger.Error("This error was unexpected. The program will exit with an error code to fail build pipelines.") + slog.Error("This error was unexpected. The program will exit with an error code to fail build pipelines.") os.Exit(1) } }