feat: add slog structured logging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8f2f3cf271
commit
ea754a85c5
@@ -14,6 +14,7 @@ type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Process ProcessConfig `yaml:"process"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP server settings.
|
||||
@@ -35,12 +36,19 @@ type ProcessConfig struct {
|
||||
OpenCodeCommand string `yaml:"opencodeCommand"`
|
||||
}
|
||||
|
||||
// LogConfig holds structured logging settings.
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
// rawConfig is used for YAML deserialization so that duration strings can be
|
||||
// parsed explicitly rather than relying on implicit time.Duration unmarshalling.
|
||||
type rawConfig struct {
|
||||
Server rawServerConfig `yaml:"server"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Process ProcessConfig `yaml:"process"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
}
|
||||
|
||||
type rawServerConfig struct {
|
||||
@@ -63,6 +71,7 @@ func Default() Config {
|
||||
},
|
||||
Workspace: WorkspaceConfig{Root: "./workspaces"},
|
||||
Process: ProcessConfig{OpenCodeCommand: "opencode"},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +121,12 @@ func applyRaw(cfg *Config, raw rawConfig) error {
|
||||
if raw.Process.OpenCodeCommand != "" {
|
||||
cfg.Process.OpenCodeCommand = raw.Process.OpenCodeCommand
|
||||
}
|
||||
if raw.Log.Level != "" {
|
||||
cfg.Log.Level = raw.Log.Level
|
||||
}
|
||||
if raw.Log.Format != "" {
|
||||
cfg.Log.Format = raw.Log.Format
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -159,5 +174,11 @@ func applyEnv(cfg *Config) error {
|
||||
if v := os.Getenv("CODESPACE_OPENCODE_COMMAND"); v != "" {
|
||||
cfg.Process.OpenCodeCommand = v
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_LOG_LEVEL"); v != "" {
|
||||
cfg.Log.Level = v
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_LOG_FORMAT"); v != "" {
|
||||
cfg.Log.Format = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,3 +94,52 @@ func TestLoadRejectsInvalidDuration(t *testing.T) {
|
||||
t.Fatal("expected invalid duration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultIncludesLogConfig(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Log.Level != "info" {
|
||||
t.Fatalf("Log.Level = %q, want info", cfg.Log.Level)
|
||||
}
|
||||
if cfg.Log.Format != "json" {
|
||||
t.Fatalf("Log.Format = %q, want json", cfg.Log.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesLogConfigFromYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
data := []byte(`log:
|
||||
level: "debug"
|
||||
format: "text"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.Log.Level != "debug" {
|
||||
t.Fatalf("Log.Level = %q, want debug", cfg.Log.Level)
|
||||
}
|
||||
if cfg.Log.Format != "text" {
|
||||
t.Fatalf("Log.Format = %q, want text", cfg.Log.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesLogEnvironmentOverrides(t *testing.T) {
|
||||
t.Setenv("CODESPACE_LOG_LEVEL", "warn")
|
||||
t.Setenv("CODESPACE_LOG_FORMAT", "json")
|
||||
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.Log.Level != "warn" {
|
||||
t.Fatalf("Log.Level = %q, want warn", cfg.Log.Level)
|
||||
}
|
||||
if cfg.Log.Format != "json" {
|
||||
t.Fatalf("Log.Format = %q, want json", cfg.Log.Format)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-4
@@ -1,11 +1,49 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"codespace/pkg/config"
|
||||
)
|
||||
|
||||
// New returns a standard library logger writing to stdout with a codespace prefix.
|
||||
func New() *log.Logger {
|
||||
return log.New(os.Stdout, "codespace ", log.LstdFlags|log.Lshortfile)
|
||||
// New creates a *slog.Logger from the given log configuration.
|
||||
// It also sets the logger as the default slog logger.
|
||||
func New(cfg config.LogConfig) (*slog.Logger, error) {
|
||||
level, err := parseLevel(cfg.Level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
var handler slog.Handler
|
||||
switch strings.ToLower(cfg.Format) {
|
||||
case "json", "":
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
case "text":
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid log format %q", cfg.Format)
|
||||
}
|
||||
|
||||
lg := slog.New(handler)
|
||||
slog.SetDefault(lg)
|
||||
return lg, nil
|
||||
}
|
||||
|
||||
func parseLevel(value string) (slog.Level, error) {
|
||||
switch strings.ToLower(value) {
|
||||
case "debug":
|
||||
return slog.LevelDebug, nil
|
||||
case "info", "":
|
||||
return slog.LevelInfo, nil
|
||||
case "warn":
|
||||
return slog.LevelWarn, nil
|
||||
case "error":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return slog.LevelInfo, fmt.Errorf("invalid log level %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codespace/pkg/config"
|
||||
)
|
||||
|
||||
func TestNewJSONInfoLogger(t *testing.T) {
|
||||
lg, err := New(config.LogConfig{Level: "info", Format: "json"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
if lg == nil {
|
||||
t.Fatal("logger is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTextDebugLogger(t *testing.T) {
|
||||
lg, err := New(config.LogConfig{Level: "debug", Format: "text"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
if lg == nil {
|
||||
t.Fatal("logger is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidLevel(t *testing.T) {
|
||||
if _, err := New(config.LogConfig{Level: "verbose", Format: "json"}); err == nil {
|
||||
t.Fatal("expected invalid level error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidFormat(t *testing.T) {
|
||||
if _, err := New(config.LogConfig{Level: "info", Format: "xml"}); err == nil {
|
||||
t.Fatal("expected invalid format error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user