Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions example/structured_logging/README.md
Original file line number Diff line number Diff line change
@@ -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.
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.
6 changes: 6 additions & 0 deletions example/structured_logging/env.example
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions example/structured_logging/logger/config.go
Original file line number Diff line number Diff line change
@@ -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
}
88 changes: 88 additions & 0 deletions example/structured_logging/logger/logger.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
28 changes: 13 additions & 15 deletions example/structured_logging/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
}
}
Expand Down
Loading