Compare commits

...

2 Commits

Author SHA1 Message Date
2a8c39002d updated hlog 2026-01-13 12:55:30 +11:00
8c2ca4d79a removed trustedhost from hws config 2026-01-13 11:32:29 +11:00
14 changed files with 1225 additions and 45 deletions

43
RULES.md Normal file
View File

@@ -0,0 +1,43 @@
# GOLIB Rules
1. All changes should be documented
Documentation is done in a few ways:
- docstrings
- README.md
- doc.go
- wiki
The README for each module should be laid out as follows:
- Title and description
- Feature list (DO NOT USE EMOTICONS)
- Installation (go get)
- Quick Start (brief example of setting up and using)
- Documentation links to the wiki
- Additional information (e.g. supported databases if package has database features)
- License
- Contributing
- Related projects (if relevant)
Docstrings and doc.go should conform to godoc standards.
Any Config structs with environment variables should have their docstrings match the format
`// ENV ENV_NAME: Description (required <optional condition>) (default: <default value>)`
where the required and default fields are only present if relevant to that variable
The wiki is located at ~/projects/golib-wiki and should be laid out as follows:
- Title and description with version number (i will manually handle commits and versioning)
- Installation
- Key Concepts and features
- Quick start
- Configuration (explicity prefer using ConfigFromEnv for packages that support it)
- Detailed sections on how to use all the features
- Integration (many of the packages in this repo are designed to work in tandem. any close integration with other packages should be mentioned here)
- Best practices
- Troubleshooting
- See also (links to other related or imported packages from this repo)
- Links (GoDoc api link, source code, issue tracker)
2. All features should have tests.
Any changes to existing features or additional features implemented should have tests created and/or updated
3. NEVER COMMIT
I will handle and manage all version control

55
hlog/config.go Normal file
View File

@@ -0,0 +1,55 @@
package hlog
import (
"git.haelnorr.com/h/golib/env"
"github.com/pkg/errors"
)
// Config holds the configuration settings for the logger.
// It can be populated from environment variables using ConfigFromEnv
// or created programmatically.
type Config struct {
LogLevel Level // ENV LOG_LEVEL: Log level for the logger - trace, debug, info, warn, error, fatal, panic (default: info)
LogOutput string // ENV LOG_OUTPUT: Output destination for logs - console, file, or both (default: console)
LogDir string // ENV LOG_DIR: Directory path for log files (required when LOG_OUTPUT is "file" or "both")
LogFileName string // ENV LOG_FILE_NAME: Name of the log file (required when LOG_OUTPUT is "file" or "both")
LogAppend bool // ENV LOG_APPEND: Append to existing log file or overwrite (default: true)
}
// ConfigFromEnv loads logger configuration from environment variables.
//
// Environment variables:
// - LOG_LEVEL: Log level (trace, debug, info, warn, error, fatal, panic) - default: info
// - LOG_OUTPUT: Output destination (console, file, both) - default: console
// - LOG_DIR: Directory for log files (required when LOG_OUTPUT is "file" or "both")
//
// Returns an error if:
// - LOG_LEVEL contains an invalid value
// - LOG_OUTPUT contains an invalid value
// - LogDir or LogFileName is not set and file logging is enabled
func ConfigFromEnv() (*Config, error) {
logLevel, err := LogLevel(env.String("LOG_LEVEL", "info"))
if err != nil {
return nil, errors.Wrap(err, "LogLevel")
}
logOutput := env.String("LOG_OUTPUT", "console")
if logOutput != "both" && logOutput != "console" && logOutput != "file" {
return nil, errors.Errorf("Invalid LOG_OUTPUT: %s", logOutput)
}
cfg := &Config{
LogLevel: logLevel,
LogOutput: logOutput,
LogDir: env.String("LOG_DIR", ""),
LogFileName: env.String("LOG_FILE_NAME", ""),
LogAppend: env.Bool("LOG_APPEND", true),
}
if cfg.LogOutput != "console" {
if cfg.LogDir == "" {
return nil, errors.New("LOG_DIR not set but file logging enabled")
}
if cfg.LogFileName == "" {
return nil, errors.New("LOG_FILE_NAME not set but file logging enabled")
}
}
return cfg, nil
}

181
hlog/config_test.go Normal file
View File

@@ -0,0 +1,181 @@
package hlog
import (
"os"
"testing"
"github.com/rs/zerolog"
)
func TestConfigFromEnv(t *testing.T) {
tests := []struct {
name string
envVars map[string]string
want *Config
wantErr bool
errMsg string
}{
{
name: "default values",
envVars: map[string]string{},
want: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "console",
LogDir: "",
LogFileName: "",
LogAppend: true,
},
wantErr: false,
},
{
name: "custom values",
envVars: map[string]string{
"LOG_LEVEL": "debug",
"LOG_OUTPUT": "both",
"LOG_DIR": "/var/log/myapp",
"LOG_FILE_NAME": "application.log",
"LOG_APPEND": "false",
},
want: &Config{
LogLevel: zerolog.DebugLevel,
LogOutput: "both",
LogDir: "/var/log/myapp",
LogFileName: "application.log",
LogAppend: false,
},
wantErr: false,
},
{
name: "file output mode",
envVars: map[string]string{
"LOG_LEVEL": "warn",
"LOG_OUTPUT": "file",
"LOG_DIR": "/tmp/logs",
"LOG_FILE_NAME": "test.log",
"LOG_APPEND": "true",
},
want: &Config{
LogLevel: zerolog.WarnLevel,
LogOutput: "file",
LogDir: "/tmp/logs",
LogFileName: "test.log",
LogAppend: true,
},
wantErr: false,
},
{
name: "invalid log level",
envVars: map[string]string{
"LOG_LEVEL": "invalid",
"LOG_OUTPUT": "console",
},
want: nil,
wantErr: true,
errMsg: "LogLevel",
},
{
name: "invalid log output",
envVars: map[string]string{
"LOG_LEVEL": "info",
"LOG_OUTPUT": "invalid",
},
want: nil,
wantErr: true,
errMsg: "Invalid LOG_OUTPUT",
},
{
name: "trace log level with defaults",
envVars: map[string]string{
"LOG_LEVEL": "trace",
"LOG_OUTPUT": "console",
},
want: &Config{
LogLevel: zerolog.TraceLevel,
LogOutput: "console",
LogDir: "",
LogFileName: "",
LogAppend: true,
},
wantErr: false,
},
{
name: "file output without LOG_DIR",
envVars: map[string]string{
"LOG_OUTPUT": "file",
"LOG_FILE_NAME": "test.log",
},
want: nil,
wantErr: true,
errMsg: "LOG_DIR not set",
},
{
name: "file output without LOG_FILE_NAME",
envVars: map[string]string{
"LOG_OUTPUT": "file",
"LOG_DIR": "/tmp",
},
want: nil,
wantErr: true,
errMsg: "LOG_FILE_NAME not set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clear all environment variables first
os.Unsetenv("LOG_LEVEL")
os.Unsetenv("LOG_OUTPUT")
os.Unsetenv("LOG_DIR")
os.Unsetenv("LOG_FILE_NAME")
os.Unsetenv("LOG_APPEND")
// Set test environment variables (only set if value provided)
for k, v := range tt.envVars {
os.Setenv(k, v)
}
// Cleanup after test
defer func() {
os.Unsetenv("LOG_LEVEL")
os.Unsetenv("LOG_OUTPUT")
os.Unsetenv("LOG_DIR")
os.Unsetenv("LOG_FILE_NAME")
os.Unsetenv("LOG_APPEND")
}()
got, err := ConfigFromEnv()
if tt.wantErr {
if err == nil {
t.Errorf("ConfigFromEnv() expected error but got nil")
return
}
if tt.errMsg != "" && err.Error() == "" {
t.Errorf("ConfigFromEnv() error = %v, should contain %v", err, tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ConfigFromEnv() unexpected error = %v", err)
return
}
if got.LogLevel != tt.want.LogLevel {
t.Errorf("ConfigFromEnv() LogLevel = %v, want %v", got.LogLevel, tt.want.LogLevel)
}
if got.LogOutput != tt.want.LogOutput {
t.Errorf("ConfigFromEnv() LogOutput = %v, want %v", got.LogOutput, tt.want.LogOutput)
}
if got.LogDir != tt.want.LogDir {
t.Errorf("ConfigFromEnv() LogDir = %v, want %v", got.LogDir, tt.want.LogDir)
}
if got.LogFileName != tt.want.LogFileName {
t.Errorf("ConfigFromEnv() LogFileName = %v, want %v", got.LogFileName, tt.want.LogFileName)
}
if got.LogAppend != tt.want.LogAppend {
t.Errorf("ConfigFromEnv() LogAppend = %v, want %v", got.LogAppend, tt.want.LogAppend)
}
})
}
}

64
hlog/doc.go Normal file
View File

@@ -0,0 +1,64 @@
// Package hlog provides a structured logging solution built on top of zerolog.
//
// hlog supports multiple output modes (console, file, or both), configurable
// log levels, and automatic log file management. It is designed to be simple
// to configure via environment variables while remaining flexible for
// programmatic configuration.
//
// # Basic Usage
//
// Create a logger with environment-based configuration:
//
// cfg, err := hlog.ConfigFromEnv()
// if err != nil {
// log.Fatal(err)
// }
//
// logger, err := hlog.NewLogger(cfg, os.Stdout)
// if err != nil {
// log.Fatal(err)
// }
// defer logger.CloseLogFile()
//
// logger.Info().Msg("Application started")
//
// # Configuration
//
// hlog can be configured via environment variables:
//
// LOG_LEVEL=info # trace, debug, info, warn, error, fatal, panic (default: info)
// LOG_OUTPUT=console # console, file, or both (default: console)
// LOG_DIR=/var/log/app # Required when LOG_OUTPUT is "file" or "both"
//
// Or programmatically:
//
// cfg := &hlog.Config{
// LogLevel: hlog.InfoLevel,
// LogOutput: "both",
// LogDir: "/var/log/myapp",
// }
//
// # Log Levels
//
// hlog supports the following log levels (from most to least verbose):
// - trace: Very detailed debugging information
// - debug: Detailed debugging information
// - info: General informational messages
// - warn: Warning messages for potentially harmful situations
// - error: Error messages for error events
// - fatal: Fatal messages that will exit the application
// - panic: Panic messages that will panic the application
//
// # Output Modes
//
// - console: Logs to the provided io.Writer (typically os.Stdout or os.Stderr)
// - file: Logs to a file named "server.log" in the configured directory
// - both: Logs to both console and file simultaneously
//
// # File Management
//
// When using file output, hlog creates a file named "server.log" in the
// specified directory. The file is opened in append mode, so logs persist
// across application restarts. Remember to call CloseLogFile() when shutting
// down your application to ensure all logs are flushed.
package hlog

View File

@@ -8,6 +8,7 @@ require (
)
require (
git.haelnorr.com/h/golib/env v0.9.1
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sys v0.12.0 // indirect

View File

@@ -1,3 +1,5 @@
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
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=

View File

@@ -5,11 +5,21 @@ import (
"github.com/rs/zerolog"
)
// Level is an alias for zerolog.Level, representing the severity of a log message.
type Level = zerolog.Level
// Takes a log level as string and converts it to a Level interface.
// If the string is not a valid input it will return InfoLevel
// Valid levels: trace, debug, info, warn, error, fatal, panic
// LogLevel converts a string to a Level value.
//
// Valid level strings (case-sensitive):
// - "trace": Most verbose, for very detailed debugging
// - "debug": Detailed debugging information
// - "info": General informational messages
// - "warn": Warning messages for potentially harmful situations
// - "error": Error messages for error events
// - "fatal": Fatal messages that will exit the application
// - "panic": Panic messages that will panic the application
//
// Returns an error if the provided string is not a valid log level.
func LogLevel(level string) (Level, error) {
levels := map[string]zerolog.Level{
"trace": zerolog.TraceLevel,

155
hlog/levels_test.go Normal file
View File

@@ -0,0 +1,155 @@
package hlog
import (
"testing"
"github.com/rs/zerolog"
)
func TestLogLevel(t *testing.T) {
tests := []struct {
name string
level string
want Level
wantErr bool
}{
{
name: "trace level",
level: "trace",
want: zerolog.TraceLevel,
wantErr: false,
},
{
name: "debug level",
level: "debug",
want: zerolog.DebugLevel,
wantErr: false,
},
{
name: "info level",
level: "info",
want: zerolog.InfoLevel,
wantErr: false,
},
{
name: "warn level",
level: "warn",
want: zerolog.WarnLevel,
wantErr: false,
},
{
name: "error level",
level: "error",
want: zerolog.ErrorLevel,
wantErr: false,
},
{
name: "fatal level",
level: "fatal",
want: zerolog.FatalLevel,
wantErr: false,
},
{
name: "panic level",
level: "panic",
want: zerolog.PanicLevel,
wantErr: false,
},
{
name: "invalid level",
level: "invalid",
want: 0,
wantErr: true,
},
{
name: "empty string",
level: "",
want: 0,
wantErr: true,
},
{
name: "uppercase level (should fail - case sensitive)",
level: "INFO",
want: 0,
wantErr: true,
},
{
name: "mixed case level (should fail - case sensitive)",
level: "Info",
want: 0,
wantErr: true,
},
{
name: "numeric string",
level: "123",
want: 0,
wantErr: true,
},
{
name: "whitespace",
level: " ",
want: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := LogLevel(tt.level)
if tt.wantErr {
if err == nil {
t.Errorf("LogLevel() expected error but got nil")
}
return
}
if err != nil {
t.Errorf("LogLevel() unexpected error = %v", err)
return
}
if got != tt.want {
t.Errorf("LogLevel() = %v, want %v", got, tt.want)
}
})
}
}
func TestLogLevel_AllValidLevels(t *testing.T) {
// Ensure all valid levels are tested
validLevels := map[string]Level{
"trace": zerolog.TraceLevel,
"debug": zerolog.DebugLevel,
"info": zerolog.InfoLevel,
"warn": zerolog.WarnLevel,
"error": zerolog.ErrorLevel,
"fatal": zerolog.FatalLevel,
"panic": zerolog.PanicLevel,
}
for levelStr, expectedLevel := range validLevels {
t.Run("valid_"+levelStr, func(t *testing.T) {
got, err := LogLevel(levelStr)
if err != nil {
t.Errorf("LogLevel(%s) unexpected error = %v", levelStr, err)
return
}
if got != expectedLevel {
t.Errorf("LogLevel(%s) = %v, want %v", levelStr, got, expectedLevel)
}
})
}
}
func TestLogLevel_ErrorMessage(t *testing.T) {
_, err := LogLevel("invalid")
if err == nil {
t.Fatal("LogLevel() expected error but got nil")
}
expectedMsg := "Invalid log level specified."
if err.Error() != expectedMsg {
t.Errorf("LogLevel() error message = %v, want %v", err.Error(), expectedMsg)
}
}

View File

@@ -7,17 +7,45 @@ import (
"github.com/pkg/errors"
)
// Returns a pointer to a new log file with the specified path.
// Remember to call file.Close() when finished writing to the log file
func NewLogFile(path string) (*os.File, error) {
logPath := filepath.Join(path, "server.log")
file, err := os.OpenFile(
logPath,
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
0663,
)
// newLogFile creates or opens the log file based on the configuration.
// The file is created in the specified directory with the configured filename.
// File permissions are set to 0663 (rw-rw--w-).
//
// If append is true, the file is opened in append mode and new logs are added
// to the end. If append is false, the file is truncated on open, overwriting
// any existing content.
//
// Returns an error if the file cannot be opened or created.
func newLogFile(dir, filename string, append bool) (*os.File, error) {
logPath := filepath.Join(dir, filename)
flags := os.O_CREATE | os.O_WRONLY
if append {
flags |= os.O_APPEND
} else {
flags |= os.O_TRUNC
}
file, err := os.OpenFile(logPath, flags, 0663)
if err != nil {
return nil, errors.Wrap(err, "os.OpenFile")
}
return file, nil
}
// CloseLogFile closes the underlying log file if one is open.
// This should be called when shutting down the application to ensure
// all buffered logs are flushed to disk.
//
// If no log file is open, this is a no-op and returns nil.
// Returns an error if the file cannot be closed.
func (l *Logger) CloseLogFile() error {
if l.logFile == nil {
return nil
}
err := l.logFile.Close()
if err != nil {
return err
}
return nil
}

242
hlog/logfile_test.go Normal file
View File

@@ -0,0 +1,242 @@
package hlog
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewLogFile(t *testing.T) {
tests := []struct {
name string
dir string
filename string
append bool
preCreate string // content to pre-create in file
write string // content to write during test
wantErr bool
}{
{
name: "create new file in append mode",
dir: t.TempDir(),
filename: "test.log",
append: true,
write: "test content",
wantErr: false,
},
{
name: "create new file in overwrite mode",
dir: t.TempDir(),
filename: "test.log",
append: false,
write: "test content",
wantErr: false,
},
{
name: "append to existing file",
dir: t.TempDir(),
filename: "existing.log",
append: true,
preCreate: "existing content\n",
write: "new content\n",
wantErr: false,
},
{
name: "overwrite existing file",
dir: t.TempDir(),
filename: "existing.log",
append: false,
preCreate: "old content\n",
write: "new content\n",
wantErr: false,
},
{
name: "invalid directory",
dir: "/nonexistent/invalid/path",
filename: "test.log",
append: true,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logPath := filepath.Join(tt.dir, tt.filename)
// Pre-create file if needed
if tt.preCreate != "" {
err := os.WriteFile(logPath, []byte(tt.preCreate), 0663)
if err != nil {
t.Fatalf("Failed to create pre-existing file: %v", err)
}
}
// Create log file
file, err := newLogFile(tt.dir, tt.filename, tt.append)
if tt.wantErr {
if err == nil {
t.Errorf("newLogFile() expected error but got nil")
if file != nil {
file.Close()
}
}
return
}
if err != nil {
t.Errorf("newLogFile() unexpected error = %v", err)
return
}
if file == nil {
t.Errorf("newLogFile() returned nil file")
return
}
defer file.Close()
// Write test content
if tt.write != "" {
_, err = file.WriteString(tt.write)
if err != nil {
t.Errorf("Failed to write to file: %v", err)
return
}
file.Sync()
}
// Verify file contents
file.Close()
content, err := os.ReadFile(logPath)
if err != nil {
t.Errorf("Failed to read file: %v", err)
return
}
contentStr := string(content)
if tt.append && tt.preCreate != "" {
// In append mode, both old and new content should exist
if !strings.Contains(contentStr, tt.preCreate) {
t.Errorf("Append mode: file missing pre-existing content. Got: %s", contentStr)
}
if !strings.Contains(contentStr, tt.write) {
t.Errorf("Append mode: file missing new content. Got: %s", contentStr)
}
} else if !tt.append && tt.preCreate != "" {
// In overwrite mode, only new content should exist
if strings.Contains(contentStr, tt.preCreate) {
t.Errorf("Overwrite mode: file still contains old content. Got: %s", contentStr)
}
if !strings.Contains(contentStr, tt.write) {
t.Errorf("Overwrite mode: file missing new content. Got: %s", contentStr)
}
} else {
// New file, should only have new content
if !strings.Contains(contentStr, tt.write) {
t.Errorf("New file: missing expected content. Got: %s", contentStr)
}
}
})
}
}
func TestNewLogFile_Permissions(t *testing.T) {
tempDir := t.TempDir()
filename := "permissions_test.log"
file, err := newLogFile(tempDir, filename, true)
if err != nil {
t.Fatalf("newLogFile() error = %v", err)
}
file.Close()
logPath := filepath.Join(tempDir, filename)
_, err = os.Stat(logPath)
if err != nil {
t.Fatalf("Failed to stat file: %v", err)
}
// Note: Actual file permissions may differ from requested permissions
// due to umask settings, so we just verify the file was created
// The OS will apply umask to the requested 0663 permissions
}
func TestNewLogFile_MultipleAppends(t *testing.T) {
tempDir := t.TempDir()
filename := "multiple_appends.log"
messages := []string{
"first message\n",
"second message\n",
"third message\n",
}
// Write messages sequentially
for _, msg := range messages {
file, err := newLogFile(tempDir, filename, true)
if err != nil {
t.Fatalf("newLogFile() error = %v", err)
}
_, err = file.WriteString(msg)
if err != nil {
t.Fatalf("WriteString() error = %v", err)
}
file.Close()
}
// Verify all messages are present
logPath := filepath.Join(tempDir, filename)
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
contentStr := string(content)
for _, msg := range messages {
if !strings.Contains(contentStr, msg) {
t.Errorf("File missing message: %s. Got: %s", msg, contentStr)
}
}
}
func TestNewLogFile_OverwriteClears(t *testing.T) {
tempDir := t.TempDir()
filename := "overwrite_clear.log"
// Create file with initial content
initialContent := "this should be removed\n"
file1, err := newLogFile(tempDir, filename, true)
if err != nil {
t.Fatalf("newLogFile() error = %v", err)
}
file1.WriteString(initialContent)
file1.Close()
// Open in overwrite mode
newContent := "new content only\n"
file2, err := newLogFile(tempDir, filename, false)
if err != nil {
t.Fatalf("newLogFile() error = %v", err)
}
file2.WriteString(newContent)
file2.Close()
// Verify only new content exists
logPath := filepath.Join(tempDir, filename)
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
contentStr := string(content)
if strings.Contains(contentStr, initialContent) {
t.Errorf("File still contains initial content after overwrite. Got: %s", contentStr)
}
if !strings.Contains(contentStr, newContent) {
t.Errorf("File missing new content. Got: %s", contentStr)
}
}

View File

@@ -9,17 +9,42 @@ import (
"github.com/rs/zerolog/pkgerrors"
)
type Logger = zerolog.Logger
// Logger wraps a zerolog.Logger and manages an optional log file.
// It embeds *zerolog.Logger, so all zerolog methods are available directly.
type Logger struct {
*zerolog.Logger
logFile *os.File
}
// Get a pointer to a new zerolog.Logger with the specified level and output
// Can provide a file, writer or both. Must provide at least one of the two
// NewLogger creates a new Logger instance based on the provided configuration.
//
// The logger output depends on cfg.LogOutput:
// - "console": Logs to the provided io.Writer w
// - "file": Logs to a file in cfg.LogDir (w can be nil)
// - "both": Logs to both the io.Writer and a file
//
// When file logging is enabled, cfg.LogDir must be set to a valid directory path.
// The log file will be named "server.log" and placed in that directory.
//
// The logger is configured with:
// - Unix timestamp format
// - Error stack trace marshaling
// - Log level from cfg.LogLevel
//
// Returns an error if:
// - cfg is nil
// - w is nil when cfg.LogOutput is not "file"
// - cfg.LogDir is empty when file logging is enabled
// - cfg.LogFileName is empty when file logging is enabled
// - The log file cannot be created
func NewLogger(
logLevel zerolog.Level,
cfg *Config,
w io.Writer,
logFile *os.File,
logDir string,
) (*Logger, error) {
if w == nil && logFile == nil {
if cfg == nil {
return nil, errors.New("No config provided")
}
if w == nil && cfg.LogOutput != "file" {
return nil, errors.New("No Writer provided for log output.")
}
@@ -31,6 +56,21 @@ func NewLogger(
consoleWriter = zerolog.ConsoleWriter{Out: w}
}
var logFile *os.File
var err error
if cfg.LogOutput == "file" || cfg.LogOutput == "both" {
if cfg.LogDir == "" {
return nil, errors.New("LOG_DIR must be set when LOG_OUTPUT is 'file' or 'both'")
}
if cfg.LogFileName == "" {
return nil, errors.New("LOG_FILE_NAME must be set when LOG_OUTPUT is 'file' or 'both'")
}
logFile, err = newLogFile(cfg.LogDir, cfg.LogFileName, cfg.LogAppend)
if err != nil {
return nil, errors.Wrap(err, "newLogFile")
}
}
var output io.Writer
if logFile != nil {
if w != nil {
@@ -41,11 +81,17 @@ func NewLogger(
} else {
output = consoleWriter
}
logger := zerolog.New(output).
With().
Timestamp().
Logger().
Level(logLevel)
Level(cfg.LogLevel)
return &logger, nil
hlog := &Logger{
Logger: &logger,
logFile: logFile,
}
return hlog, nil
}

376
hlog/logger_test.go Normal file
View File

@@ -0,0 +1,376 @@
package hlog
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/rs/zerolog"
)
func TestNewLogger(t *testing.T) {
tests := []struct {
name string
cfg *Config
writer io.Writer
wantErr bool
errMsg string
}{
{
name: "console output only",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "console",
LogDir: "",
LogFileName: "",
LogAppend: true,
},
writer: bytes.NewBuffer(nil),
wantErr: false,
},
{
name: "nil config",
cfg: nil,
writer: bytes.NewBuffer(nil),
wantErr: true,
errMsg: "No config provided",
},
{
name: "nil writer for both output",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "both",
},
writer: nil,
wantErr: true,
errMsg: "No Writer provided",
},
{
name: "file output without LogDir",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: "",
LogFileName: "test.log",
LogAppend: true,
},
writer: nil,
wantErr: true,
errMsg: "LOG_DIR must be set",
},
{
name: "file output without LogFileName",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: "/tmp",
LogFileName: "",
LogAppend: true,
},
writer: nil,
wantErr: true,
errMsg: "LOG_FILE_NAME must be set",
},
{
name: "both output without LogDir",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "both",
LogDir: "",
LogFileName: "test.log",
LogAppend: true,
},
writer: bytes.NewBuffer(nil),
wantErr: true,
errMsg: "LOG_DIR must be set",
},
{
name: "both output without LogFileName",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "both",
LogDir: "/tmp",
LogFileName: "",
LogAppend: true,
},
writer: bytes.NewBuffer(nil),
wantErr: true,
errMsg: "LOG_FILE_NAME must be set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger, err := NewLogger(tt.cfg, tt.writer)
if tt.wantErr {
if err == nil {
t.Errorf("NewLogger() expected error but got nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("NewLogger() error = %v, should contain %v", err, tt.errMsg)
}
return
}
if err != nil {
t.Errorf("NewLogger() unexpected error = %v", err)
return
}
if logger == nil {
t.Errorf("NewLogger() returned nil logger")
return
}
if logger.Logger == nil {
t.Errorf("NewLogger() returned logger with nil zerolog.Logger")
}
})
}
}
func TestNewLogger_FileOutput(t *testing.T) {
// Create temporary directory for test logs
tempDir := t.TempDir()
tests := []struct {
name string
cfg *Config
writer io.Writer
wantErr bool
checkFile bool
logMessage string
}{
{
name: "file output with append",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: "append_test.log",
LogAppend: true,
},
writer: nil,
wantErr: false,
checkFile: true,
logMessage: "test append message",
},
{
name: "file output with overwrite",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: "overwrite_test.log",
LogAppend: false,
},
writer: nil,
wantErr: false,
checkFile: true,
logMessage: "test overwrite message",
},
{
name: "both output modes",
cfg: &Config{
LogLevel: zerolog.DebugLevel,
LogOutput: "both",
LogDir: tempDir,
LogFileName: "both_test.log",
LogAppend: true,
},
writer: bytes.NewBuffer(nil),
wantErr: false,
checkFile: true,
logMessage: "test both message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger, err := NewLogger(tt.cfg, tt.writer)
if tt.wantErr {
if err == nil {
t.Errorf("NewLogger() expected error but got nil")
}
return
}
if err != nil {
t.Errorf("NewLogger() unexpected error = %v", err)
return
}
if logger == nil {
t.Errorf("NewLogger() returned nil logger")
return
}
// Log a test message
logger.Info().Msg(tt.logMessage)
// Close the log file to flush
err = logger.CloseLogFile()
if err != nil {
t.Errorf("CloseLogFile() error = %v", err)
}
// Check if file exists and contains message
if tt.checkFile {
logPath := filepath.Join(tt.cfg.LogDir, tt.cfg.LogFileName)
content, err := os.ReadFile(logPath)
if err != nil {
t.Errorf("Failed to read log file: %v", err)
return
}
if !strings.Contains(string(content), tt.logMessage) {
t.Errorf("Log file doesn't contain expected message. Got: %s", string(content))
}
}
// Check console output for "both" mode
if tt.cfg.LogOutput == "both" && tt.writer != nil {
if buf, ok := tt.writer.(*bytes.Buffer); ok {
consoleOutput := buf.String()
if !strings.Contains(consoleOutput, tt.logMessage) {
t.Errorf("Console output doesn't contain expected message. Got: %s", consoleOutput)
}
}
}
})
}
}
func TestNewLogger_AppendVsOverwrite(t *testing.T) {
tempDir := t.TempDir()
logFileName := "append_vs_overwrite.log"
// First logger - write initial content
cfg1 := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: logFileName,
LogAppend: true,
}
logger1, err := NewLogger(cfg1, nil)
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
logger1.Info().Msg("first message")
logger1.CloseLogFile()
// Second logger - append mode
cfg2 := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: logFileName,
LogAppend: true,
}
logger2, err := NewLogger(cfg2, nil)
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
logger2.Info().Msg("second message")
logger2.CloseLogFile()
// Check both messages exist
logPath := filepath.Join(tempDir, logFileName)
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
contentStr := string(content)
if !strings.Contains(contentStr, "first message") {
t.Errorf("Log file missing 'first message' after append")
}
if !strings.Contains(contentStr, "second message") {
t.Errorf("Log file missing 'second message' after append")
}
// Third logger - overwrite mode
cfg3 := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: logFileName,
LogAppend: false,
}
logger3, err := NewLogger(cfg3, nil)
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
logger3.Info().Msg("third message")
logger3.CloseLogFile()
// Check only third message exists
content, err = os.ReadFile(logPath)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
contentStr = string(content)
if strings.Contains(contentStr, "first message") {
t.Errorf("Log file still contains 'first message' after overwrite")
}
if strings.Contains(contentStr, "second message") {
t.Errorf("Log file still contains 'second message' after overwrite")
}
if !strings.Contains(contentStr, "third message") {
t.Errorf("Log file missing 'third message' after overwrite")
}
}
func TestLogger_CloseLogFile(t *testing.T) {
t.Run("close with file", func(t *testing.T) {
tempDir := t.TempDir()
cfg := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: "close_test.log",
LogAppend: true,
}
logger, err := NewLogger(cfg, nil)
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
err = logger.CloseLogFile()
if err != nil {
t.Errorf("CloseLogFile() error = %v", err)
}
})
t.Run("close without file", func(t *testing.T) {
cfg := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "console",
}
logger, err := NewLogger(cfg, bytes.NewBuffer(nil))
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
err = logger.CloseLogFile()
if err != nil {
t.Errorf("CloseLogFile() should not error when no file is open, got: %v", err)
}
})
}

View File

@@ -9,7 +9,6 @@ import (
type Config struct {
Host string // ENV HWS_HOST: Host to listen on (default: 127.0.0.1)
Port uint64 // ENV HWS_PORT: Port to listen on (default: 3000)
TrustedHost string // ENV HWS_TRUSTED_HOST: Domain/Hostname to accept as trusted (default: same as Host)
GZIP bool // ENV HWS_GZIP: Flag for GZIP compression on requests (default: false)
ReadHeaderTimeout time.Duration // ENV HWS_READ_HEADER_TIMEOUT: Timeout for reading request headers in seconds (default: 2)
WriteTimeout time.Duration // ENV HWS_WRITE_TIMEOUT: Timeout for writing requests in seconds (default: 10)
@@ -18,13 +17,9 @@ type Config struct {
// ConfigFromEnv returns a Config struct loaded from the environment variables
func ConfigFromEnv() (*Config, error) {
host := env.String("HWS_HOST", "127.0.0.1")
trustedHost := env.String("HWS_TRUSTED_HOST", host)
cfg := &Config{
Host: host,
Host: env.String("HWS_HOST", "127.0.0.1"),
Port: env.UInt64("HWS_PORT", 3000),
TrustedHost: trustedHost,
GZIP: env.Bool("HWS_GZIP", false),
ReadHeaderTimeout: time.Duration(env.Int("HWS_READ_HEADER_TIMEOUT", 2)) * time.Second,
WriteTimeout: time.Duration(env.Int("HWS_WRITE_TIMEOUT", 10)) * time.Second,

View File

@@ -15,7 +15,6 @@ func Test_ConfigFromEnv(t *testing.T) {
// Clear any existing env vars
os.Unsetenv("HWS_HOST")
os.Unsetenv("HWS_PORT")
os.Unsetenv("HWS_TRUSTED_HOST")
os.Unsetenv("HWS_GZIP")
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
os.Unsetenv("HWS_WRITE_TIMEOUT")
@@ -27,7 +26,6 @@ func Test_ConfigFromEnv(t *testing.T) {
assert.Equal(t, "127.0.0.1", config.Host)
assert.Equal(t, uint64(3000), config.Port)
assert.Equal(t, "127.0.0.1", config.TrustedHost)
assert.Equal(t, false, config.GZIP)
assert.Equal(t, 2*time.Second, config.ReadHeaderTimeout)
assert.Equal(t, 10*time.Second, config.WriteTimeout)
@@ -41,7 +39,6 @@ func Test_ConfigFromEnv(t *testing.T) {
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
assert.Equal(t, "192.168.1.1", config.Host)
assert.Equal(t, "192.168.1.1", config.TrustedHost) // Should match host by default
})
t.Run("Custom port", func(t *testing.T) {
@@ -53,18 +50,6 @@ func Test_ConfigFromEnv(t *testing.T) {
assert.Equal(t, uint64(8080), config.Port)
})
t.Run("Custom trusted host", func(t *testing.T) {
os.Setenv("HWS_HOST", "127.0.0.1")
os.Setenv("HWS_TRUSTED_HOST", "example.com")
defer os.Unsetenv("HWS_HOST")
defer os.Unsetenv("HWS_TRUSTED_HOST")
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
assert.Equal(t, "127.0.0.1", config.Host)
assert.Equal(t, "example.com", config.TrustedHost)
})
t.Run("GZIP enabled", func(t *testing.T) {
os.Setenv("HWS_GZIP", "true")
defer os.Unsetenv("HWS_GZIP")
@@ -92,7 +77,6 @@ func Test_ConfigFromEnv(t *testing.T) {
t.Run("All custom values", func(t *testing.T) {
os.Setenv("HWS_HOST", "0.0.0.0")
os.Setenv("HWS_PORT", "9000")
os.Setenv("HWS_TRUSTED_HOST", "myapp.com")
os.Setenv("HWS_GZIP", "true")
os.Setenv("HWS_READ_HEADER_TIMEOUT", "3")
os.Setenv("HWS_WRITE_TIMEOUT", "15")
@@ -100,7 +84,6 @@ func Test_ConfigFromEnv(t *testing.T) {
defer func() {
os.Unsetenv("HWS_HOST")
os.Unsetenv("HWS_PORT")
os.Unsetenv("HWS_TRUSTED_HOST")
os.Unsetenv("HWS_GZIP")
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
os.Unsetenv("HWS_WRITE_TIMEOUT")
@@ -111,7 +94,6 @@ func Test_ConfigFromEnv(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "0.0.0.0", config.Host)
assert.Equal(t, uint64(9000), config.Port)
assert.Equal(t, "myapp.com", config.TrustedHost)
assert.Equal(t, true, config.GZIP)
assert.Equal(t, 3*time.Second, config.ReadHeaderTimeout)
assert.Equal(t, 15*time.Second, config.WriteTimeout)