feat: add slog structured logging

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-02 16:34:42 +08:00
co-authored by Claude Fable 5
parent 8f2f3cf271
commit ea754a85c5
16 changed files with 1432 additions and 82 deletions
+21
View File
@@ -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
}
+49
View File
@@ -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
View File
@@ -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)
}
}
+39
View File
@@ -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")
}
}