8.7 KiB
slog Structured Logging Design
日期:2026-07-02
目标
为 codespace 后端增加基于 Go 标准库 log/slog 的结构化日志。目标是建立清晰、低噪音、不会泄露用户内容的日志边界,而不是到处撒日志。
本次目标:
- 使用
log/slog替代当前log.Logger。 - 默认输出 JSON 日志。
- 支持日志 level 和 format 配置。
- 为 Gin 增加 request logging middleware。
- 记录 workspace/process 生命周期关键事件。
- 建立全局约束:后续所有应用日志都必须使用
log/slog。 - 不记录文件内容、请求 body、token、环境变量、OpenCode 输出、宿主机绝对路径。
当前状态
当前代码:
pkg/logger.New()返回*log.Logger。cmd/server/main.go使用log.Fatalf和lg.Printf。internal/api.NewRouter(...)没有 logger 参数。- Gin router 使用
gin.New()+gin.Recovery()。 - service/process/workspace 层基本没有日志。
非目标
本次不做:
- 不引入第三方日志库。
- 不接入 OpenTelemetry、Prometheus、ELK、Loki。
- 不实现 request ID / trace ID。
- 不记录文件内容、请求 body、OpenCode stdout/stderr。
- 不记录 workspace root 绝对路径。
- 不在高频文件读写路径里打业务日志。
- 不增加认证、CORS、rate limit、Git、LSP、Agent 功能。
全局日志约束
从本次改动开始,后续所有应用日志必须走 log/slog。
禁止在应用代码中重新引入这些旁路日志方式:
log.Printf/log.Println/log.Fatal/log.Fatalf,除非是在 logger 初始化失败前的最后兜底。fmt.Print*用作日志。gin.Logger()或gin.Default()隐式 request logger。- 第三方日志库。
允许:
- 测试中使用
t.Log*。 - CLI/工具脚本中显式面向用户的 stdout 输出,但不能混进服务端运行路径。
- logger 初始化失败之前,
cmd/server/main.go可以使用标准库log.Fatalf作为兜底,因为此时 slog logger 尚未存在。
验证方式:
grep -R "log\.Printf\|log\.Println\|log\.Fatal\|fmt\.Print\|gin\.Default\|gin\.Logger" cmd internal pkg || true
预期只允许出现 logger 初始化失败前的兜底 log.Fatalf,不允许出现运行期旁路日志。
配置设计
configs/config.yaml 增加:
log:
level: "info"
format: "json"
pkg/config 增加:
type LogConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
Config 增加:
type Config struct {
Server ServerConfig `yaml:"server"`
Workspace WorkspaceConfig `yaml:"workspace"`
Process ProcessConfig `yaml:"process"`
Log LogConfig `yaml:"log"`
}
默认值:
level:infoformat:json
环境变量:
CODESPACE_LOG_LEVELCODESPACE_LOG_FORMAT
支持 level:
debuginfowarnerror
支持 format:
jsontext
虽然默认使用 JSON,但保留 text 是低成本本地调试能力。
Logger 包设计
pkg/logger 改为创建 *slog.Logger:
func New(cfg config.LogConfig) (*slog.Logger, error)
行为:
format=json使用slog.NewJSONHandler(os.Stdout, opts)。format=text使用slog.NewTextHandler(os.Stdout, opts)。level解析成slog.LevelDebug/Info/Warn/Error。- 非法 level 或 format 返回 error。
- 成功创建后调用
slog.SetDefault(lg)。
不保留旧 *log.Logger 包装层。项目还小,直接切干净。
main.go 设计
启动流程:
- 先加载 config。
- 用
logger.New(cfg.Log)初始化 slog。 - 初始化失败时使用标准库
log.Fatalf,因为此时结构化 logger 还不存在。 - 后续全部使用
lg.Info/Warn/Error。
启动日志:
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,
)
注意:启动日志可以记录配置里的 workspace root,因为这是服务启动配置;但 request/process/workspace lifecycle 日志不记录宿主机绝对路径。
错误日志:
lg.Error("server failed", "error", err)
os.Exit(1)
slog 没有 Fatal,不要自己造复杂 wrapper。
Gin request logging middleware
新增 middleware:
func requestLogger(lg *slog.Logger) gin.HandlerFunc
api.NewRouter 签名改为:
func NewRouter(
workspaces *service.WorkspaceService,
files *service.FileService,
processes *service.ProcessService,
lg *slog.Logger,
) *gin.Engine
如果 lg == nil,使用 slog.Default()。
middleware 字段:
methodpathstatuslatency_msclient_iperror
level 规则:
- 2xx/3xx:
Info - 4xx:
Warn - 5xx:
Error
/healthz 不记录,避免健康检查刷屏。
request logging 不记录:
- request body
- response body
- query string 中的敏感内容
path 使用路由模板或 URL path,不包含 query string。
业务日志边界
WorkspaceService
增加 logger 字段:
type WorkspaceService struct {
workspaces workspace.Manager
processes process.Manager
logger *slog.Logger
}
构造函数接受 logger:
func NewWorkspaceService(workspaces workspace.Manager, processes process.Manager, lg *slog.Logger) *WorkspaceService
如果 lg == nil,使用 slog.Default()。
记录:
- create 成功:
workspace created,字段workspace_id - delete 成功:
workspace deleted,字段workspace_id - delete 前停止进程失败:
failed to stop workspace process before delete,字段workspace_id、error
不记录 workspace root。
ProcessService
增加 logger 字段:
type ProcessService struct {
workspaces workspace.Manager
processes process.Manager
logger *slog.Logger
}
构造函数接受 logger:
func NewProcessService(workspaces workspace.Manager, processes process.Manager, lg *slog.Logger) *ProcessService
如果 lg == nil,使用 slog.Default()。
记录:
- start 成功:
process started,字段workspace_id、pid - start 失败:
process start failed,字段workspace_id、error - stop 成功:
process stopped,字段workspace_id - stop 失败:
process stop failed,字段workspace_id、error - restart 成功:
process restarted,字段workspace_id、pid - restart 失败:
process restart failed,字段workspace_id、error
不记录 command、env、workspace root。
FileService
第一版不增加业务日志。文件操作高频且容易泄露用户路径/内容。request logging 已足够覆盖 HTTP 访问。
API 兼容性
现有 API 路径、method、响应 JSON 不变。
唯一函数签名变化是内部构造函数:
api.NewRouter(..., lg *slog.Logger)service.NewWorkspaceService(..., lg *slog.Logger)service.NewProcessService(..., lg *slog.Logger)
测试可传 nil,由实现回退到 slog.Default()。
测试策略
logger tests
覆盖:
logger.New(config.LogConfig{Level: "info", Format: "json"})成功。logger.New(config.LogConfig{Level: "debug", Format: "text"})成功。- 非法 level 返回 error。
- 非法 format 返回 error。
config tests
覆盖:
- 默认 log config 是
info/json。 - YAML 能覆盖 log config。
- 环境变量能覆盖 log config。
API tests
现有 httptest 行为不变。
额外覆盖:
api.NewRouter(..., nil)不 panic。/healthz正常返回,不受 logging middleware 影响。
全局验证
必须通过:
gofmt -w cmd internal pkg
go test ./...
go run ./cmd/server
curl http://localhost:8080/healthz
风险与处理
- 风险:日志泄露用户内容。处理:禁止记录 body、文件内容、OpenCode 输出、env、token。
- 风险:日志噪音过大。处理:不记录
/healthz,不记录 FileService 高频业务日志。 - 风险:logger 注入让构造函数变多。处理:只给 router、workspace service、process service 加 logger;FileService 不加。
- 风险:测试输出被 JSON 日志污染。处理:测试传 nil 或使用
slog.New(slog.NewTextHandler(io.Discard, nil))。
完成标准
完成后满足:
pkg/logger使用log/slog。cmd/server/main.go不再使用lg.Printf。api.NewRouter接受 logger 并安装 request logging middleware。- workspace/process 生命周期有结构化日志。
- log 配置支持 YAML 和环境变量。
- 全局约束检查中无运行期旁路日志;只允许 logger 初始化失败前的兜底
log.Fatalf。 - server smoke test 通过。