Framework
Logging
The pkg/log package provides structured JSON logging. Every log entry is a JSON object with a timestamp, level, message, and optional fields.
import "github.com/stanza-go/framework/pkg/log"
Creating a logger
logger := log.New(
log.WithLevel(log.LevelInfo), // minimum level (default: Info)
log.WithWriter(os.Stdout), // output destination (default: Stdout)
log.WithFields(log.String("app", "stanza")), // fields on every entry
)
Log levels
Four levels in ascending severity:
logger.Debug("cache hit", log.String("key", "user:42"))
logger.Info("request handled", log.Int("status", 200), log.Duration("latency", elapsed))
logger.Warn("slow query", log.Duration("duration", d), log.String("query", sql))
logger.Error("failed to connect", log.Err(err))
Entries below the configured level are discarded. Parse levels from strings:
level := log.ParseLevel("debug") // LevelDebug
level := log.ParseLevel("info") // LevelInfo
level := log.ParseLevel("warn") // LevelWarn
level := log.ParseLevel("error") // LevelError
level := log.ParseLevel("unknown") // LevelInfo (default)
Fields
Fields are typed key-value pairs. Use the constructor functions for type safety:
log.String("key", "value")
log.Int("count", 42)
log.Int64("id", 1234567890)
log.Float64("rate", 0.95)
log.Bool("active", true)
log.Err(err) // key is always "error"
log.Duration("latency", 150*time.Millisecond)
log.Time("started_at", time.Now()) // RFC3339 in UTC
log.Any("data", someStruct) // arbitrary value
Child loggers
Create loggers with pre-set fields using With:
reqLogger := logger.With(
log.String("request_id", requestID),
log.String("method", r.Method),
log.String("path", r.URL.Path),
)
reqLogger.Info("handling request")
// output includes request_id, method, path on every entry
Child loggers share the parent's writer and mutex, so writes are serialized.
File rotation
Write logs to files with automatic rotation by date or size:
fw, err := log.NewFileWriter("/var/log/myapp",
log.WithMaxSize(100 * 1024 * 1024), // 100MB per file (default)
log.WithMaxFiles(7), // keep 7 rotated files (default)
)
if err != nil {
return err
}
defer fw.Close()
logger := log.New(
log.WithWriter(fw),
log.WithLevel(log.LevelInfo),
)
Rotation triggers when the date changes (UTC) or the file exceeds maxSize. Old files are pruned to keep at most maxFiles.
Multiple outputs
Write to both stdout and a file using io.MultiWriter:
fw, _ := log.NewFileWriter(logDir)
writer := io.MultiWriter(os.Stdout, fw)
logger := log.New(log.WithWriter(writer))
Output format
Every entry is a single JSON line:
{"time":"2026-03-21T10:30:00Z","level":"info","msg":"request handled","method":"GET","path":"/api/health","status":200,"duration":"1.2ms"}
Fields from the logger, child logger, and individual log call are merged. The time, level, and msg keys are always present.
In a Stanza app
func provideLogger(lc *lifecycle.Lifecycle, dir *datadir.Dir, cfg *config.Config) *log.Logger {
fw, _ := log.NewFileWriter(dir.Path("logs"))
lc.Append(lifecycle.Hook{
OnStop: func(ctx context.Context) error {
return fw.Close()
},
})
return log.New(
log.WithLevel(log.ParseLevel(cfg.GetString("log.level"))),
log.WithWriter(io.MultiWriter(os.Stdout, fw)),
)
}