feat: add slog structured logging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8f2f3cf271
commit
ea754a85c5
@@ -0,0 +1,350 @@
|
||||
# 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 尚未存在。
|
||||
|
||||
验证方式:
|
||||
|
||||
```sh
|
||||
grep -R "log\.Printf\|log\.Println\|log\.Fatal\|fmt\.Print\|gin\.Default\|gin\.Logger" cmd internal pkg || true
|
||||
```
|
||||
|
||||
预期只允许出现 logger 初始化失败前的兜底 `log.Fatalf`,不允许出现运行期旁路日志。
|
||||
|
||||
## 配置设计
|
||||
|
||||
`configs/config.yaml` 增加:
|
||||
|
||||
```yaml
|
||||
log:
|
||||
level: "info"
|
||||
format: "json"
|
||||
```
|
||||
|
||||
`pkg/config` 增加:
|
||||
|
||||
```go
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
```
|
||||
|
||||
`Config` 增加:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Process ProcessConfig `yaml:"process"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
}
|
||||
```
|
||||
|
||||
默认值:
|
||||
|
||||
- `level`: `info`
|
||||
- `format`: `json`
|
||||
|
||||
环境变量:
|
||||
|
||||
- `CODESPACE_LOG_LEVEL`
|
||||
- `CODESPACE_LOG_FORMAT`
|
||||
|
||||
支持 level:
|
||||
|
||||
- `debug`
|
||||
- `info`
|
||||
- `warn`
|
||||
- `error`
|
||||
|
||||
支持 format:
|
||||
|
||||
- `json`
|
||||
- `text`
|
||||
|
||||
虽然默认使用 JSON,但保留 text 是低成本本地调试能力。
|
||||
|
||||
## Logger 包设计
|
||||
|
||||
`pkg/logger` 改为创建 `*slog.Logger`:
|
||||
|
||||
```go
|
||||
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 设计
|
||||
|
||||
启动流程:
|
||||
|
||||
1. 先加载 config。
|
||||
2. 用 `logger.New(cfg.Log)` 初始化 slog。
|
||||
3. 初始化失败时使用标准库 `log.Fatalf`,因为此时结构化 logger 还不存在。
|
||||
4. 后续全部使用 `lg.Info/Warn/Error`。
|
||||
|
||||
启动日志:
|
||||
|
||||
```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,
|
||||
)
|
||||
```
|
||||
|
||||
注意:启动日志可以记录配置里的 workspace root,因为这是服务启动配置;但 request/process/workspace lifecycle 日志不记录宿主机绝对路径。
|
||||
|
||||
错误日志:
|
||||
|
||||
```go
|
||||
lg.Error("server failed", "error", err)
|
||||
os.Exit(1)
|
||||
```
|
||||
|
||||
`slog` 没有 `Fatal`,不要自己造复杂 wrapper。
|
||||
|
||||
## Gin request logging middleware
|
||||
|
||||
新增 middleware:
|
||||
|
||||
```go
|
||||
func requestLogger(lg *slog.Logger) gin.HandlerFunc
|
||||
```
|
||||
|
||||
`api.NewRouter` 签名改为:
|
||||
|
||||
```go
|
||||
func NewRouter(
|
||||
workspaces *service.WorkspaceService,
|
||||
files *service.FileService,
|
||||
processes *service.ProcessService,
|
||||
lg *slog.Logger,
|
||||
) *gin.Engine
|
||||
```
|
||||
|
||||
如果 `lg == nil`,使用 `slog.Default()`。
|
||||
|
||||
middleware 字段:
|
||||
|
||||
- `method`
|
||||
- `path`
|
||||
- `status`
|
||||
- `latency_ms`
|
||||
- `client_ip`
|
||||
- `error`
|
||||
|
||||
level 规则:
|
||||
|
||||
- 2xx/3xx:`Info`
|
||||
- 4xx:`Warn`
|
||||
- 5xx:`Error`
|
||||
|
||||
`/healthz` 不记录,避免健康检查刷屏。
|
||||
|
||||
request logging 不记录:
|
||||
|
||||
- request body
|
||||
- response body
|
||||
- query string 中的敏感内容
|
||||
|
||||
`path` 使用路由模板或 URL path,不包含 query string。
|
||||
|
||||
## 业务日志边界
|
||||
|
||||
### WorkspaceService
|
||||
|
||||
增加 logger 字段:
|
||||
|
||||
```go
|
||||
type WorkspaceService struct {
|
||||
workspaces workspace.Manager
|
||||
processes process.Manager
|
||||
logger *slog.Logger
|
||||
}
|
||||
```
|
||||
|
||||
构造函数接受 logger:
|
||||
|
||||
```go
|
||||
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 字段:
|
||||
|
||||
```go
|
||||
type ProcessService struct {
|
||||
workspaces workspace.Manager
|
||||
processes process.Manager
|
||||
logger *slog.Logger
|
||||
}
|
||||
```
|
||||
|
||||
构造函数接受 logger:
|
||||
|
||||
```go
|
||||
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 影响。
|
||||
|
||||
### 全局验证
|
||||
|
||||
必须通过:
|
||||
|
||||
```sh
|
||||
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 通过。
|
||||
Reference in New Issue
Block a user