# slog Structured Logging Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add structured application logging using the Go standard library `log/slog`, with JSON output by default and a global slog-only logging rule. **Architecture:** `pkg/logger` becomes the single application logger factory and returns `*slog.Logger`. `cmd/server/main.go` creates one logger from config and injects it into Gin router and services. Gin request logging is implemented as custom middleware; workspace/process lifecycle logs live in service layer. **Tech Stack:** Go 1.25, standard library `log/slog`, Gin, existing YAML config via `gopkg.in/yaml.v3`. ## Global Constraints - Use `log/slog` for all application logs from this change forward. - Default log output is JSON. - Support log `level` and `format` config. - Do not introduce any third-party logging library. - Do not use `gin.Default()` or `gin.Logger()`. - Do not use `fmt.Print*` as application logging. - Do not use `log.Printf`, `log.Println`, `log.Fatal`, or `log.Fatalf` in runtime application paths; only `cmd/server/main.go` may use standard library `log.Fatalf` before slog initialization exists. - Do not log file contents, request bodies, response bodies, OpenCode stdout/stderr, environment variables, tokens, secrets, or workspace root in request/process/workspace lifecycle logs. - Do not add request ID, tracing, OpenTelemetry, Prometheus, ELK, Loki, auth, CORS, rate limiting, Git, LSP, watcher streaming, frontend, or AI Agent features. - Do not change existing API paths, methods, or response JSON shapes. - Code generation/refactoring must be delegated to Codex MCP using exactly `model: "kimi/kimi-k2.7-code"`, `sandbox: "danger-full-access"`, and `approval-policy: "on-failure"`. - Do not commit unless the user explicitly asks. --- ## File Structure ### Modify - `configs/config.yaml` — add `log.level` and `log.format`. - `pkg/config/config.go` — add `LogConfig`, defaults, YAML load, env overrides. - `pkg/config/config_test.go` — extend config tests for log defaults/YAML/env. - `pkg/logger/logger.go` — replace `log.Logger` factory with `slog.Logger` factory. - `cmd/server/main.go` — initialize slog logger and use structured logs. - `internal/api/router.go` — accept logger, install custom request logging middleware. - `internal/api/router_test.go` — update `NewRouter` calls to pass nil logger. - `internal/service/workspace_service.go` — inject logger and add workspace lifecycle logs. - `internal/service/process_service.go` — inject logger and add process lifecycle logs. - Existing tests under `internal/api` and packages that construct services — update constructors to include logger parameter or nil. - `README.md` — document log config and slog-only logging rule. ### Create - `pkg/logger/logger_test.go` — tests logger factory success/failure. - `internal/api/logging_middleware.go` — Gin request logging middleware. --- ## Task 1: Add log config **Files:** - Modify: `configs/config.yaml` - Modify: `pkg/config/config.go` - Modify: `pkg/config/config_test.go` **Interfaces:** - Produces: `config.LogConfig` with fields `Level string` and `Format string`. - Produces: `Config.Log LogConfig`. - Produces env overrides `CODESPACE_LOG_LEVEL` and `CODESPACE_LOG_FORMAT`. - [ ] **Step 1: Extend config tests first** Update `pkg/config/config_test.go` to add these assertions to existing tests: ```go 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) } } ``` - [ ] **Step 2: Run config tests to see failure** Run: ```sh go test ./pkg/config ``` Expected: - Fails because `Config.Log` and `LogConfig` do not exist yet. - [ ] **Step 3: Implement log config** Modify `pkg/config/config.go`: ```go type Config struct { Server ServerConfig `yaml:"server"` Workspace WorkspaceConfig `yaml:"workspace"` Process ProcessConfig `yaml:"process"` Log LogConfig `yaml:"log"` } type LogConfig struct { Level string `yaml:"level"` Format string `yaml:"format"` } ``` Update `rawConfig`: ```go type rawConfig struct { Server rawServerConfig `yaml:"server"` Workspace WorkspaceConfig `yaml:"workspace"` Process ProcessConfig `yaml:"process"` Log LogConfig `yaml:"log"` } ``` Update defaults: ```go Log: LogConfig{Level: "info", Format: "json"}, ``` Update `applyRaw`: ```go if raw.Log.Level != "" { cfg.Log.Level = raw.Log.Level } if raw.Log.Format != "" { cfg.Log.Format = raw.Log.Format } ``` Update `applyEnv`: ```go if v := os.Getenv("CODESPACE_LOG_LEVEL"); v != "" { cfg.Log.Level = v } if v := os.Getenv("CODESPACE_LOG_FORMAT"); v != "" { cfg.Log.Format = v } ``` - [ ] **Step 4: Update config YAML** Modify `configs/config.yaml`: ```yaml log: level: "info" format: "json" ``` Keep existing `server`, `workspace`, and `process` blocks unchanged. - [ ] **Step 5: Verify config task** Run: ```sh gofmt -w pkg/config go test ./pkg/config ``` Expected: - Config tests pass. --- ## Task 2: Replace logger package with slog **Files:** - Modify: `pkg/logger/logger.go` - Create: `pkg/logger/logger_test.go` **Interfaces:** - Consumes: `config.LogConfig`. - Produces: `logger.New(cfg config.LogConfig) (*slog.Logger, error)`. - Produces: slog-only application logging factory. - [ ] **Step 1: Write logger tests first** Create `pkg/logger/logger_test.go`: ```go 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") } } ``` - [ ] **Step 2: Run logger tests to see failure** Run: ```sh go test ./pkg/logger ``` Expected: - Fails because `logger.New` still has the old signature and returns `*log.Logger`. - [ ] **Step 3: Implement slog logger factory** Replace `pkg/logger/logger.go` with: ```go package logger import ( "fmt" "log/slog" "os" "strings" "codespace/pkg/config" ) 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) } } ``` - [ ] **Step 4: Verify logger package** Run: ```sh gofmt -w pkg/logger go test ./pkg/logger ``` Expected: - Logger tests pass. --- ## Task 3: Add Gin request logging middleware **Files:** - Create: `internal/api/logging_middleware.go` - Modify: `internal/api/router.go` - Modify: `internal/api/router_test.go` **Interfaces:** - Consumes: `*slog.Logger`. - Produces: `requestLogger(lg *slog.Logger) gin.HandlerFunc`. - Produces: `api.NewRouter(..., lg *slog.Logger) *gin.Engine`. - [ ] **Step 1: Update router test setup** Modify `internal/api/router_test.go` setup to pass nil logger: ```go return NewRouter(wsSvc, fileSvc, procSvc, nil) ``` Existing tests must keep the same response assertions. - [ ] **Step 2: Implement request logging middleware** Create `internal/api/logging_middleware.go`: ```go package api import ( "log/slog" "net/http" "time" "github.com/gin-gonic/gin" ) func requestLogger(lg *slog.Logger) gin.HandlerFunc { if lg == nil { lg = slog.Default() } return func(c *gin.Context) { if c.FullPath() == "/healthz" || c.Request.URL.Path == "/healthz" { c.Next() return } start := time.Now() c.Next() status := c.Writer.Status() path := c.FullPath() if path == "" { path = c.Request.URL.Path } attrs := []any{ "method", c.Request.Method, "path", path, "status", status, "latency_ms", time.Since(start).Milliseconds(), "client_ip", c.ClientIP(), } if len(c.Errors) > 0 { attrs = append(attrs, "error", c.Errors.String()) } switch { case status >= http.StatusInternalServerError: lg.Error("http request completed", attrs...) case status >= http.StatusBadRequest: lg.Warn("http request completed", attrs...) default: lg.Info("http request completed", attrs...) } } } ``` - [ ] **Step 3: Update router signature and middleware** Modify `internal/api/router.go`: ```go func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService, lg *slog.Logger) *gin.Engine ``` Add imports: ```go "log/slog" ``` After `r.Use(gin.Recovery())`, add: ```go r.Use(requestLogger(lg)) ``` Keep `gin.New()` and do not use `gin.Default()` or `gin.Logger()`. - [ ] **Step 4: Verify API package** Run: ```sh gofmt -w internal/api go test ./internal/api ``` Expected: - API tests pass. --- ## Task 4: Inject slog into services and main **Files:** - Modify: `cmd/server/main.go` - Modify: `internal/service/workspace_service.go` - Modify: `internal/service/process_service.go` - Modify tests that call changed constructors. **Interfaces:** - Consumes: `*slog.Logger`. - Produces: `service.NewWorkspaceService(workspaces workspace.Manager, processes process.Manager, lg *slog.Logger) *WorkspaceService`. - Produces: `service.NewProcessService(workspaces workspace.Manager, processes process.Manager, lg *slog.Logger) *ProcessService`. - [ ] **Step 1: Update WorkspaceService** Modify `internal/service/workspace_service.go`: - Add `log/slog` import. - Add `logger *slog.Logger` field. - Change constructor to accept logger. - If nil, use `slog.Default()`. - Log successful create/delete. - Log failure to stop process before delete. Required log messages: ```go s.logger.Info("workspace created", "workspace_id", id) s.logger.Error("failed to stop workspace process before delete", "workspace_id", id, "error", err) s.logger.Info("workspace deleted", "workspace_id", id) ``` Do not log workspace root. - [ ] **Step 2: Update ProcessService** Modify `internal/service/process_service.go`: - Add `log/slog` import. - Add `logger *slog.Logger` field. - Change constructor to accept logger. - If nil, use `slog.Default()`. - Log start/stop/restart success and failure. Required log messages: ```go s.logger.Info("process started", "workspace_id", workspaceID, "pid", status.PID) s.logger.Error("process start failed", "workspace_id", workspaceID, "error", err) s.logger.Info("process stopped", "workspace_id", workspaceID) s.logger.Error("process stop failed", "workspace_id", workspaceID, "error", err) s.logger.Info("process restarted", "workspace_id", workspaceID, "pid", status.PID) s.logger.Error("process restart failed", "workspace_id", workspaceID, "error", err) ``` Do not log command, env, or workspace root. - [ ] **Step 3: Update main wiring** Modify `cmd/server/main.go`: - Keep standard library `log` only for config/logger initialization failure before slog exists. - Initialize logger: ```go lg, err := logger.New(cfg.Log) if err != nil { log.Fatalf("failed to initialize logger: %v", err) } ``` - Pass logger into services/router: ```go workspaceSvc := service.NewWorkspaceService(workspaces, processes, lg) fileSvc := service.NewFileService(workspaces) processSvc := service.NewProcessService(workspaces, processes, lg) router := api.NewRouter(workspaceSvc, fileSvc, processSvc, lg) ``` - Replace `lg.Printf` with slog calls: ```go lg.Info("server starting", "addr", cfg.Server.Addr, "workspace_root", cfg.Workspace.Root, "read_timeout", cfg.Server.ReadTimeout.String(), "write_timeout", cfg.Server.WriteTimeout.String(), "idle_timeout", cfg.Server.IdleTimeout.String(), "max_header_bytes", cfg.Server.MaxHeaderBytes, ) ``` - Replace server goroutine fatal path: ```go lg.Error("server failed", "error", err) os.Exit(1) ``` - Replace shutdown logs: ```go lg.Info("server shutting down") lg.Error("server forced shutdown", "error", err) os.Exit(1) ``` - [ ] **Step 4: Update constructor call sites** Update all call sites in tests and code: ```go service.NewWorkspaceService(wsMgr, procMgr, nil) service.NewProcessService(wsMgr, procMgr, nil) api.NewRouter(wsSvc, fileSvc, procSvc, nil) ``` Run this grep to find remaining old signatures: ```sh grep -R "NewWorkspaceService\|NewProcessService\|NewRouter" -n cmd internal pkg | grep -v vendor ``` Expected: - All constructor calls include logger where required. - [ ] **Step 5: Verify packages** Run: ```sh gofmt -w cmd internal pkg go test ./internal/service ./internal/api ./cmd/server ``` Expected: - Tests/compilation pass. --- ## Task 5: Documentation and global slog-only validation **Files:** - Modify: `README.md` - Modify: docs/spec already updated; no new spec edits needed unless implementation differs. **Interfaces:** - Produces documented logging config and slog-only rule. - [ ] **Step 1: Update README** Add a logging configuration section: ```markdown ## Logging codespace uses the Go standard library `log/slog` for application logs. Default config: ```yaml log: level: "info" format: "json" ``` Environment overrides: - `CODESPACE_LOG_LEVEL` (`debug`, `info`, `warn`, `error`) - `CODESPACE_LOG_FORMAT` (`json`, `text`) All application logs must go through `log/slog`. Do not use `fmt.Print*`, `log.Printf`, `gin.Logger()`, or third-party logging libraries in server runtime code. ``` - [ ] **Step 2: Run final validation** Run: ```sh gofmt -w cmd internal pkg go mod tidy go test ./... ``` Expected: - All tests pass. - [ ] **Step 3: Check slog-only constraint** Run: ```sh grep -R "log\.Printf\|log\.Println\|log\.Fatal\|fmt\.Print\|gin\.Default\|gin\.Logger" cmd internal pkg || true ``` Expected: - Only allowed match is the logger-initialization fallback in `cmd/server/main.go`, if present. - No `fmt.Print*`, `gin.Default`, `gin.Logger`, or runtime `log.Printf`/`log.Println` matches. - [ ] **Step 4: Run server smoke test** Run: ```sh (go run ./cmd/server > /tmp/codespace-server.log 2>&1 & pid=$!; \ for i in $(seq 1 30); do \ if curl -fsS http://localhost:8080/healthz; then \ kill $pid; wait $pid 2>/dev/null || true; exit 0; \ fi; \ sleep 0.2; \ done; \ kill $pid 2>/dev/null || true; \ wait $pid 2>/dev/null || true; \ cat /tmp/codespace-server.log; exit 1) ``` Expected: ```json {"status":"ok"} ``` - [ ] **Step 5: Inspect git diff scope** Run: ```sh git diff --stat git status --short ``` Expected: - Changes are limited to logging config, logger package, API middleware/router signature, workspace/process service logging, docs, and tests. - No unrelated auth/CORS/Git/LSP/frontend changes. --- ## Codex Execution Prompt Use this exact structure when dispatching Codex: ```text ## Context - Project root: /Users/taochen/llm/codespace - Current branch: main - Go baseline: 1.25 - Existing API router uses Gin and explicit http.Server composition. - Design spec: docs/superpowers/specs/2026-07-02-slog-logging-design.md - Implementation plan: docs/superpowers/plans/2026-07-02-slog-logging.md ## Task Add structured logging with Go standard library log/slog. Steps: 1. Add log config (`log.level`, `log.format`) with YAML/env support. 2. Replace pkg/logger with slog logger factory. 3. Add Gin request logging middleware, skipping /healthz. 4. Inject slog logger into main, API router, WorkspaceService, and ProcessService. 5. Add workspace/process lifecycle logs. 6. Update README and tests. 7. Run final validation. ## Constraints - All application logs must use log/slog from now on. - Do not use fmt.Print* as logging. - Do not use gin.Default() or gin.Logger(). - Do not use log.Printf/log.Println/log.Fatal/log.Fatalf in runtime paths; only cmd/server/main.go may use log.Fatalf before slog initialization exists. - Do not log file contents, request bodies, response bodies, OpenCode stdout/stderr, environment variables, tokens, secrets, command, env, or workspace root in request/process/workspace lifecycle logs. - Do not change API paths, methods, or response JSON shapes. - Do not add third-party logging dependencies or unrelated features. - Do not commit. ## Acceptance - gofmt -w cmd internal pkg succeeds. - go mod tidy succeeds. - go test ./... passes. - slog-only grep has no disallowed runtime logging. - go run ./cmd/server starts and /healthz returns {"status":"ok"}. ``` --- ## Self-Review - Spec coverage: Plan covers config, logger package, request middleware, service lifecycle logs, main wiring, README, and slog-only validation. - Placeholder scan: No TBD/TODO/ambiguous implementation steps remain. - Type consistency: `logger.New(config.LogConfig) (*slog.Logger, error)`, `api.NewRouter(..., *slog.Logger)`, `NewWorkspaceService(..., *slog.Logger)`, and `NewProcessService(..., *slog.Logger)` are consistent across tasks. - Scope check: Plan is limited to logging. It does not add request IDs, tracing, third-party logging, auth, CORS, Git, LSP, frontend, watcher streaming, or Agent features.