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
@@ -31,6 +31,8 @@ The server listens on `:8080` by default. Override via `configs/config.yaml` or
|
||||
| `CODESPACE_WRITE_TIMEOUT` | HTTP write timeout (e.g. `15s`) |
|
||||
| `CODESPACE_IDLE_TIMEOUT` | HTTP idle timeout (e.g. `60s`) |
|
||||
| `CODESPACE_MAX_HEADER_BYTES` | Max header bytes (e.g. `1048576`) |
|
||||
| `CODESPACE_LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) |
|
||||
| `CODESPACE_LOG_FORMAT` | Log format (`json`, `text`) |
|
||||
|
||||
## Test
|
||||
|
||||
@@ -53,10 +55,30 @@ workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
log:
|
||||
level: "info"
|
||||
format: "json"
|
||||
```
|
||||
|
||||
The server is started through an explicit `http.Server{Handler: router}` — it does not use `gin.Engine.Run()`.
|
||||
|
||||
## Logging
|
||||
|
||||
codespace uses the Go standard library `log/slog` for application logs. Default output is JSON to stdout.
|
||||
|
||||
```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.
|
||||
|
||||
## API examples
|
||||
|
||||
Health check:
|
||||
|
||||
+20
-8
@@ -23,16 +23,19 @@ func main() {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
lg := logger.New()
|
||||
lg, err := logger.New(cfg.Log)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize logger: %v", err)
|
||||
}
|
||||
|
||||
workspaces := workspace.NewLocalManager(cfg.Workspace.Root)
|
||||
processes := process.NewManager(cfg.Process.OpenCodeCommand)
|
||||
|
||||
workspaceSvc := service.NewWorkspaceService(workspaces, processes)
|
||||
workspaceSvc := service.NewWorkspaceService(workspaces, processes, lg)
|
||||
fileSvc := service.NewFileService(workspaces)
|
||||
processSvc := service.NewProcessService(workspaces, processes)
|
||||
processSvc := service.NewProcessService(workspaces, processes, lg)
|
||||
|
||||
router := api.NewRouter(workspaceSvc, fileSvc, processSvc)
|
||||
router := api.NewRouter(workspaceSvc, fileSvc, processSvc, lg)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.Server.Addr,
|
||||
@@ -43,11 +46,19 @@ func main() {
|
||||
MaxHeaderBytes: cfg.Server.MaxHeaderBytes,
|
||||
}
|
||||
|
||||
lg.Printf("listening on %s, workspace root: %s", cfg.Server.Addr, cfg.Workspace.Root)
|
||||
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,
|
||||
)
|
||||
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
lg.Fatalf("server error: %v", err)
|
||||
lg.Error("server failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -55,10 +66,11 @@ func main() {
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
lg.Printf("shutting down...")
|
||||
lg.Info("server shutting down")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
lg.Fatalf("forced shutdown: %v", err)
|
||||
lg.Error("server forced shutdown", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,6 @@ workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
log:
|
||||
level: "info"
|
||||
format: "json"
|
||||
|
||||
@@ -0,0 +1,759 @@
|
||||
# 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.
|
||||
@@ -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 通过。
|
||||
@@ -12,7 +12,6 @@ require (
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -35,31 +21,25 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -67,70 +47,52 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
|
||||
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
|
||||
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
|
||||
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// requestLogger returns a Gin middleware that logs each request via slog.
|
||||
// Requests to /healthz are skipped to avoid health-check noise.
|
||||
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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/service"
|
||||
@@ -9,10 +10,11 @@ import (
|
||||
)
|
||||
|
||||
// NewRouter builds a Gin engine with all API routes registered.
|
||||
func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) *gin.Engine {
|
||||
//gin.SetMode(gin.ReleaseMode)
|
||||
func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService, lg *slog.Logger) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(requestLogger(lg))
|
||||
|
||||
wsHandler := &workspaceHandler{svc: workspaces}
|
||||
fileHandler := &fileHandler{svc: files}
|
||||
|
||||
@@ -17,10 +17,10 @@ func setupTestRouter(t *testing.T) http.Handler {
|
||||
root := t.TempDir()
|
||||
wsMgr := workspace.NewLocalManager(root)
|
||||
procMgr := process.NewManager("")
|
||||
wsSvc := service.NewWorkspaceService(wsMgr, procMgr)
|
||||
wsSvc := service.NewWorkspaceService(wsMgr, procMgr, nil)
|
||||
fileSvc := service.NewFileService(wsMgr)
|
||||
procSvc := service.NewProcessService(wsMgr, procMgr)
|
||||
return NewRouter(wsSvc, fileSvc, procSvc)
|
||||
procSvc := service.NewProcessService(wsMgr, procMgr, nil)
|
||||
return NewRouter(wsSvc, fileSvc, procSvc, nil)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"codespace/internal/process"
|
||||
"codespace/internal/workspace"
|
||||
)
|
||||
@@ -9,11 +11,16 @@ import (
|
||||
type ProcessService struct {
|
||||
workspaces workspace.Manager
|
||||
processes process.Manager
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewProcessService creates a ProcessService.
|
||||
func NewProcessService(workspaces workspace.Manager, processes process.Manager) *ProcessService {
|
||||
return &ProcessService{workspaces: workspaces, processes: processes}
|
||||
// If lg is nil, slog.Default() is used.
|
||||
func NewProcessService(workspaces workspace.Manager, processes process.Manager, lg *slog.Logger) *ProcessService {
|
||||
if lg == nil {
|
||||
lg = slog.Default()
|
||||
}
|
||||
return &ProcessService{workspaces: workspaces, processes: processes, logger: lg}
|
||||
}
|
||||
|
||||
// Start starts an OpenCode process for the given workspace.
|
||||
@@ -22,12 +29,23 @@ func (s *ProcessService) Start(workspaceID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.processes.Start(workspaceID, ws.Root)
|
||||
if err := s.processes.Start(workspaceID, ws.Root); err != nil {
|
||||
s.logger.Error("process start failed", "workspace_id", workspaceID, "error", err)
|
||||
return err
|
||||
}
|
||||
status := s.processes.Status(workspaceID)
|
||||
s.logger.Info("process started", "workspace_id", workspaceID, "pid", status.PID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the OpenCode process for the given workspace.
|
||||
func (s *ProcessService) Stop(workspaceID string) error {
|
||||
return s.processes.Stop(workspaceID)
|
||||
if err := s.processes.Stop(workspaceID); err != nil {
|
||||
s.logger.Error("process stop failed", "workspace_id", workspaceID, "error", err)
|
||||
return err
|
||||
}
|
||||
s.logger.Info("process stopped", "workspace_id", workspaceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart restarts the OpenCode process for the given workspace.
|
||||
@@ -36,7 +54,13 @@ func (s *ProcessService) Restart(workspaceID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.processes.Restart(workspaceID, ws.Root)
|
||||
if err := s.processes.Restart(workspaceID, ws.Root); err != nil {
|
||||
s.logger.Error("process restart failed", "workspace_id", workspaceID, "error", err)
|
||||
return err
|
||||
}
|
||||
status := s.processes.Status(workspaceID)
|
||||
s.logger.Info("process restarted", "workspace_id", workspaceID, "pid", status.PID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the process status for the given workspace.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"codespace/internal/process"
|
||||
"codespace/internal/workspace"
|
||||
)
|
||||
@@ -9,16 +11,26 @@ import (
|
||||
type WorkspaceService struct {
|
||||
workspaces workspace.Manager
|
||||
processes process.Manager
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWorkspaceService creates a WorkspaceService.
|
||||
func NewWorkspaceService(workspaces workspace.Manager, processes process.Manager) *WorkspaceService {
|
||||
return &WorkspaceService{workspaces: workspaces, processes: processes}
|
||||
// If lg is nil, slog.Default() is used.
|
||||
func NewWorkspaceService(workspaces workspace.Manager, processes process.Manager, lg *slog.Logger) *WorkspaceService {
|
||||
if lg == nil {
|
||||
lg = slog.Default()
|
||||
}
|
||||
return &WorkspaceService{workspaces: workspaces, processes: processes, logger: lg}
|
||||
}
|
||||
|
||||
// Create creates a new workspace.
|
||||
func (s *WorkspaceService) Create(id string) (*workspace.Workspace, error) {
|
||||
return s.workspaces.Create(id)
|
||||
ws, err := s.workspaces.Create(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Info("workspace created", "workspace_id", id)
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// Get retrieves a workspace by id.
|
||||
@@ -30,7 +42,13 @@ func (s *WorkspaceService) Get(id string) (*workspace.Workspace, error) {
|
||||
func (s *WorkspaceService) Delete(id string) error {
|
||||
status := s.processes.Status(id)
|
||||
if status.Running {
|
||||
_ = s.processes.Stop(id)
|
||||
if err := s.processes.Stop(id); err != nil {
|
||||
s.logger.Error("failed to stop workspace process before delete", "workspace_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
return s.workspaces.Delete(id)
|
||||
if err := s.workspaces.Delete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("workspace deleted", "workspace_id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Process ProcessConfig `yaml:"process"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP server settings.
|
||||
@@ -35,12 +36,19 @@ type ProcessConfig struct {
|
||||
OpenCodeCommand string `yaml:"opencodeCommand"`
|
||||
}
|
||||
|
||||
// LogConfig holds structured logging settings.
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
// rawConfig is used for YAML deserialization so that duration strings can be
|
||||
// parsed explicitly rather than relying on implicit time.Duration unmarshalling.
|
||||
type rawConfig struct {
|
||||
Server rawServerConfig `yaml:"server"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Process ProcessConfig `yaml:"process"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
}
|
||||
|
||||
type rawServerConfig struct {
|
||||
@@ -63,6 +71,7 @@ func Default() Config {
|
||||
},
|
||||
Workspace: WorkspaceConfig{Root: "./workspaces"},
|
||||
Process: ProcessConfig{OpenCodeCommand: "opencode"},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +121,12 @@ func applyRaw(cfg *Config, raw rawConfig) error {
|
||||
if raw.Process.OpenCodeCommand != "" {
|
||||
cfg.Process.OpenCodeCommand = raw.Process.OpenCodeCommand
|
||||
}
|
||||
if raw.Log.Level != "" {
|
||||
cfg.Log.Level = raw.Log.Level
|
||||
}
|
||||
if raw.Log.Format != "" {
|
||||
cfg.Log.Format = raw.Log.Format
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -159,5 +174,11 @@ func applyEnv(cfg *Config) error {
|
||||
if v := os.Getenv("CODESPACE_OPENCODE_COMMAND"); v != "" {
|
||||
cfg.Process.OpenCodeCommand = v
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_LOG_LEVEL"); v != "" {
|
||||
cfg.Log.Level = v
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_LOG_FORMAT"); v != "" {
|
||||
cfg.Log.Format = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,3 +94,52 @@ func TestLoadRejectsInvalidDuration(t *testing.T) {
|
||||
t.Fatal("expected invalid duration error")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-4
@@ -1,11 +1,49 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"codespace/pkg/config"
|
||||
)
|
||||
|
||||
// New returns a standard library logger writing to stdout with a codespace prefix.
|
||||
func New() *log.Logger {
|
||||
return log.New(os.Stdout, "codespace ", log.LstdFlags|log.Lshortfile)
|
||||
// New creates a *slog.Logger from the given log configuration.
|
||||
// It also sets the logger as the default slog logger.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user