20 KiB
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/slogfor all application logs from this change forward. - Default log output is JSON.
- Support log
levelandformatconfig. - Do not introduce any third-party logging library.
- Do not use
gin.Default()orgin.Logger(). - Do not use
fmt.Print*as application logging. - Do not use
log.Printf,log.Println,log.Fatal, orlog.Fatalfin runtime application paths; onlycmd/server/main.gomay use standard librarylog.Fatalfbefore 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", andapproval-policy: "on-failure". - Do not commit unless the user explicitly asks.
File Structure
Modify
configs/config.yaml— addlog.levelandlog.format.pkg/config/config.go— addLogConfig, defaults, YAML load, env overrides.pkg/config/config_test.go— extend config tests for log defaults/YAML/env.pkg/logger/logger.go— replacelog.Loggerfactory withslog.Loggerfactory.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— updateNewRoutercalls 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/apiand 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.LogConfigwith fieldsLevel stringandFormat string. -
Produces:
Config.Log LogConfig. -
Produces env overrides
CODESPACE_LOG_LEVELandCODESPACE_LOG_FORMAT. -
Step 1: Extend config tests first
Update pkg/config/config_test.go to add these assertions to existing tests:
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:
go test ./pkg/config
Expected:
-
Fails because
Config.LogandLogConfigdo not exist yet. -
Step 3: Implement log config
Modify pkg/config/config.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:
type rawConfig struct {
Server rawServerConfig `yaml:"server"`
Workspace WorkspaceConfig `yaml:"workspace"`
Process ProcessConfig `yaml:"process"`
Log LogConfig `yaml:"log"`
}
Update defaults:
Log: LogConfig{Level: "info", Format: "json"},
Update applyRaw:
if raw.Log.Level != "" {
cfg.Log.Level = raw.Log.Level
}
if raw.Log.Format != "" {
cfg.Log.Format = raw.Log.Format
}
Update applyEnv:
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:
log:
level: "info"
format: "json"
Keep existing server, workspace, and process blocks unchanged.
- Step 5: Verify config task
Run:
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:
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:
go test ./pkg/logger
Expected:
-
Fails because
logger.Newstill has the old signature and returns*log.Logger. -
Step 3: Implement slog logger factory
Replace pkg/logger/logger.go with:
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:
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:
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:
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:
func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService, lg *slog.Logger) *gin.Engine
Add imports:
"log/slog"
After r.Use(gin.Recovery()), add:
r.Use(requestLogger(lg))
Keep gin.New() and do not use gin.Default() or gin.Logger().
- Step 4: Verify API package
Run:
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/slogimport. - Add
logger *slog.Loggerfield. - Change constructor to accept logger.
- If nil, use
slog.Default(). - Log successful create/delete.
- Log failure to stop process before delete.
Required log messages:
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/slogimport. - Add
logger *slog.Loggerfield. - Change constructor to accept logger.
- If nil, use
slog.Default(). - Log start/stop/restart success and failure.
Required log messages:
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
logonly for config/logger initialization failure before slog exists. - Initialize logger:
lg, err := logger.New(cfg.Log)
if err != nil {
log.Fatalf("failed to initialize logger: %v", err)
}
- Pass logger into services/router:
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.Printfwith slog calls:
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:
lg.Error("server failed", "error", err)
os.Exit(1)
- Replace shutdown logs:
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:
service.NewWorkspaceService(wsMgr, procMgr, nil)
service.NewProcessService(wsMgr, procMgr, nil)
api.NewRouter(wsSvc, fileSvc, procSvc, nil)
Run this grep to find remaining old signatures:
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:
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:
## 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:
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 runtimelog.Printf/log.Printlnmatches. -
Step 4: Run server smoke test
Run:
(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:
{"status":"ok"}
- Step 5: Inspect git diff scope
Run:
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:
## 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), andNewProcessService(..., *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.