This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/docs/superpowers/plans/2026-07-02-slog-logging.md
T
2026-07-02 16:34:42 +08:00

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/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:

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.Log and LogConfig do 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.New still 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/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:

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:

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:
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.Printf with 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 runtime log.Printf/log.Println matches.

  • 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), 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.