diff --git a/.gitignore b/.gitignore index 1290bde7..be0ff49b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ coverage.out coverage.html # Logs and temporary files +/log/ *.log *.bak *~ diff --git a/cmd/mcp-stdio/main.go b/cmd/mcp-stdio/main.go index ad7b8f63..94f91ec0 100644 --- a/cmd/mcp-stdio/main.go +++ b/cmd/mcp-stdio/main.go @@ -25,7 +25,12 @@ func main() { } // 初始化日志(stdio 模式下使用 stderr 输出日志,避免干扰 JSON-RPC 通信) - log := logger.New(cfg.Log.Level, "stderr") + log := logger.New(cfg.Log.Level, "stderr", logger.DiagnosticOptions{ + Dir: cfg.Log.DiagnosticDir, + Disabled: cfg.Log.DiagnosticDisabled, + RetentionDays: cfg.Log.DiagnosticRetentionDays, + }) + defer log.Sync() // 创建MCP服务器 mcpServer := mcp.NewServer(log.Logger) diff --git a/cmd/server/main.go b/cmd/server/main.go index d45619a7..194b449b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -101,7 +101,12 @@ func main() { } // 初始化日志 - log := logger.New(cfg.Log.Level, cfg.Log.Output) + log := logger.New(cfg.Log.Level, cfg.Log.Output, logger.DiagnosticOptions{ + Dir: cfg.Log.DiagnosticDir, + Disabled: cfg.Log.DiagnosticDisabled, + RetentionDays: cfg.Log.DiagnosticRetentionDays, + }) + defer log.Sync() // 创建可取消的根 context,用于优雅关闭 ctx, cancel := context.WithCancel(context.Background()) diff --git a/config.example.yaml b/config.example.yaml index 40e091e8..f9254a31 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -36,6 +36,9 @@ auth: log: level: info # 日志级别: debug(调试), info(信息), warn(警告), error(错误) output: stdout # 日志输出位置: stdout(标准输出), stderr(标准错误), 或文件路径 + diagnostic_dir: log # 额外保存 warn 及以上诊断日志,按本地日期拆分;相对于进程工作目录 + diagnostic_retention_days: 14 # 保留天数(含当天);省略或 <= 0 使用 14 天 + diagnostic_disabled: false # true 关闭额外诊断日志;修改后需重启 # 平台操作审计(系统设置 -> 日志审计;不记录对话正文与每次工具调用) audit: enabled: true diff --git a/docs/en-US/configuration.md b/docs/en-US/configuration.md index f04c1da1..f49d0129 100644 --- a/docs/en-US/configuration.md +++ b/docs/en-US/configuration.md @@ -143,3 +143,9 @@ After changing, validate the specific subsystem rather than trusting the save me - Config API and apply: `internal/handler/config.go` - Route registration: `internal/app/app.go` - C2 reconciliation: `internal/app/c2_lifecycle.go` + +## Diagnostic logs + +Alongside `log.output` (controlled by `log.level`), warnings and errors are saved as JSON Lines in `log/diagnostic-YYYY-MM-DD.log`, using the server’s local date. This independent warn-and-above output preserves existing context, caller information, and error stack traces; ordinary info/debug records are excluded and no extra request bodies or tool output are collected. + +Configure `log.diagnostic_dir` (default `log`, relative to the working directory), `log.diagnostic_retention_days` (default 14, including today; nonpositive values use the default), or `log.diagnostic_disabled: true` to disable it. Restart after changing these settings. Files are created only when a diagnostic record is written; the first write each day removes expired files matching `diagnostic-YYYY-MM-DD.log`. Cleanup does not run while no diagnostic records are written. Write failures are reported to stderr without interrupting the primary log output. diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 5c9dc1ad..0db87d4a 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -27,7 +27,11 @@ log: - Chromium 浏览器插件的合法 `chrome-extension://<32位插件ID>` Origin 会被自动识别,无需配置。插件仍需按域授权,并使用密码登录与 Bearer Token 调用 API。 - `server.cors_allowed_origins`:仅供其他可信 Web 集成使用的额外 Origin 精确白名单;不支持 `*`,修改后需重启服务。 - `auth.session_duration_hours`:登录会话有效期(小时)。登录密码由 RBAC 用户管理,首次启动时在控制台输出 `admin` 初始密码。 -- `log.output`:可以是 `stdout`、`stderr` 或文件路径。 +- `log.output`:可以是 `stdout`、`stderr` 或文件路径,由 `log.level` 控制级别。 +- 额外诊断日志默认开启,仅记录 `warn` 及以上(包括重试、连接异常和错误),独立于 `log.level`,不保存普通 `info` / `debug` 日志。保留原有结构化字段、时间、代码位置和 Error 及以上堆栈,不额外采集请求正文或工具输出。 +- `log.diagnostic_dir`:默认 `log`,相对于进程工作目录,文件名为 `diagnostic-YYYY-MM-DD.log`(JSON Lines,按服务器本地日期拆分)。只有出现诊断日志时才创建目录和文件;跨天后首次写入切换文件。 +- `log.diagnostic_retention_days`:默认 14 天(含当天);省略或小于等于 0 时使用默认值。每天首次写入时清理此目录内过期的 `diagnostic-日期.log`,不删除其他文件;没有新诊断日志时不执行清理。 +- `log.diagnostic_disabled: true`:关闭额外诊断落盘。以上日志配置修改后需重启;目录无法写入时保留原输出,并由 Zap 向 stderr 报告写入失败。 ## AI 通道与模型配置 diff --git a/internal/config/config.go b/internal/config/config.go index 1b32666d..4fb988a4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -807,8 +807,11 @@ type ServerConfig struct { } type LogConfig struct { - Level string `yaml:"level"` - Output string `yaml:"output"` + Level string `yaml:"level"` + Output string `yaml:"output"` + DiagnosticDir string `yaml:"diagnostic_dir"` + DiagnosticDisabled bool `yaml:"diagnostic_disabled"` + DiagnosticRetentionDays int `yaml:"diagnostic_retention_days"` } type MCPConfig struct { diff --git a/internal/logger/daily_writer.go b/internal/logger/daily_writer.go new file mode 100644 index 00000000..a5f4ccce --- /dev/null +++ b/internal/logger/daily_writer.go @@ -0,0 +1,88 @@ +package logger + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// DiagnosticOptions controls the additional warn-and-above diagnostic output. +type DiagnosticOptions struct { + Dir string + Disabled bool + RetentionDays int // Values <= 0 use the default of 14 calendar days. +} + +// dailyWriter opens lazily: healthy runs create no diagnostic files. Opening +// per write also avoids keeping descriptors open across rotation or shutdown. +type dailyWriter struct { + mu sync.Mutex + dir string + retentionDays int + cleanedDay string + now func() time.Time +} + +func newDailyWriter(options DiagnosticOptions) *dailyWriter { + if options.Dir == "" { + options.Dir = "log" + } + if options.RetentionDays <= 0 { + options.RetentionDays = 14 + } + return &dailyWriter{dir: options.Dir, retentionDays: options.RetentionDays, now: time.Now} +} + +func (w *dailyWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + now := w.now() + day := now.Format(time.DateOnly) + if err := os.MkdirAll(w.dir, 0700); err != nil { + return 0, fmt.Errorf("create diagnostic log directory: %w", err) + } + f, err := os.OpenFile(filepath.Join(w.dir, "diagnostic-"+day+".log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return 0, fmt.Errorf("open diagnostic log: %w", err) + } + n, writeErr := f.Write(p) + closeErr := f.Close() + var cleanupErr error + if w.cleanedDay != day { + cleanupErr = w.cleanup(now) + if cleanupErr == nil { + w.cleanedDay = day + } + } + return n, errors.Join(writeErr, closeErr, cleanupErr) +} + +// Writes are unbuffered and files are closed before Write returns. +func (w *dailyWriter) Sync() error { return nil } + +func (w *dailyWriter) cleanup(now time.Time) error { + entries, err := os.ReadDir(w.dir) + if err != nil { + return err + } + cutoff := now.AddDate(0, 0, -(w.retentionDays - 1)).Format(time.DateOnly) + var errs []error + for _, entry := range entries { + name := entry.Name() + if !entry.Type().IsRegular() || !strings.HasPrefix(name, "diagnostic-") || !strings.HasSuffix(name, ".log") { + continue + } + day := strings.TrimSuffix(strings.TrimPrefix(name, "diagnostic-"), ".log") + if _, err := time.Parse(time.DateOnly, day); err != nil || day >= cutoff { + continue + } + if err := os.Remove(filepath.Join(w.dir, name)); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Errorf("remove expired diagnostic log %s: %w", name, err)) + } + } + return errors.Join(errs...) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 7e306fab..1a47e275 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -11,7 +11,7 @@ type Logger struct { *zap.Logger } -func New(level, output string) *Logger { +func New(level, output string, diagnostics ...DiagnosticOptions) *Logger { var zapLevel zapcore.Level switch level { case "debug": @@ -34,6 +34,8 @@ func New(level, output string) *Logger { var writeSyncer zapcore.WriteSyncer if output == "stdout" { writeSyncer = zapcore.AddSync(os.Stdout) + } else if output == "stderr" { + writeSyncer = zapcore.AddSync(os.Stderr) } else { file, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { @@ -49,6 +51,18 @@ func New(level, output string) *Logger { zapLevel, ) + options := DiagnosticOptions{} + if len(diagnostics) > 0 { + options = diagnostics[0] + } + if !options.Disabled { + // The diagnostic threshold is independent of the primary output level. + core = zapcore.NewTee(core, zapcore.NewCore( + zapcore.NewJSONEncoder(config.EncoderConfig), + newDailyWriter(options), zapcore.WarnLevel, + )) + } + logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) return &Logger{Logger: logger} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 00000000..f0d07d48 --- /dev/null +++ b/internal/logger/logger_test.go @@ -0,0 +1,120 @@ +package logger + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestDiagnosticFiltering(t *testing.T) { + for _, level := range []string{"debug", "error"} { + t.Run(level, func(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "log") + log := New(level, filepath.Join(root, "primary.log"), DiagnosticOptions{Dir: dir}) + log.Debug("debug") + log.Info("info") + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("ordinary logs created diagnostic directory: %v", err) + } + child := log.With(zap.String("conversation_id", "test-id")) + child.Warn("retry", zap.Int("attempt", 2)) + child.Error("failed", zap.Error(fmt.Errorf("test failure"))) + files, _ := filepath.Glob(filepath.Join(dir, "*.log")) + if len(files) != 1 { + t.Fatalf("files: %v", files) + } + data, err := os.ReadFile(files[0]) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("unexpected diagnostic records: %s", data) + } + for i, line := range lines { + var record map[string]interface{} + if err := json.Unmarshal([]byte(line), &record); err != nil { + t.Fatal(err) + } + if record["conversation_id"] != "test-id" || record["timestamp"] == nil || record["caller"] == nil { + t.Fatalf("missing diagnostic context: %v", record) + } + if i == 1 && (record["stacktrace"] == nil || record["error"] != "test failure") { + t.Fatalf("missing error details: %v", record) + } + } + }) + } +} + +func TestDiagnosticDisabled(t *testing.T) { + dir := filepath.Join(t.TempDir(), "log") + log := New("error", os.DevNull, DiagnosticOptions{Dir: dir, Disabled: true}) + log.Error("failure") + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("disabled diagnostics wrote files: %v", err) + } +} + +func TestDailyRotationRetentionAndConcurrency(t *testing.T) { + dir := t.TempDir() + w := newDailyWriter(DiagnosticOptions{Dir: dir, RetentionDays: 2}) + now := time.Date(2026, 9, 8, 23, 59, 59, 0, time.Local) + w.now = func() time.Time { return now } + for _, name := range []string{"diagnostic-2026-09-06.log", "diagnostic-2026-09-07.log", "other.log", "diagnostic-invalid.log"} { + if err := os.WriteFile(filepath.Join(dir, name), nil, 0600); err != nil { + t.Fatal(err) + } + } + if _, err := w.Write([]byte("before midnight\n")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "diagnostic-2026-09-06.log")); !os.IsNotExist(err) { + t.Fatal("expired file remains") + } + now = now.Add(2 * time.Second) + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := w.Write([]byte("after midnight\n")); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + for name, count := range map[string]int{"diagnostic-2026-09-08.log": 1, "diagnostic-2026-09-09.log": 50} { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil || strings.Count(string(data), "\n") != count { + t.Fatalf("%s: %q, %v", name, data, err) + } + } + if _, err := os.Stat(filepath.Join(dir, "diagnostic-2026-09-07.log")); !os.IsNotExist(err) { + t.Fatal("rotation did not expire old file") + } + for _, name := range []string{"other.log", "diagnostic-invalid.log"} { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + } +} + +func TestDiagnosticWriteFailureKeepsPrimaryOutput(t *testing.T) { + root := t.TempDir() + primary := filepath.Join(root, "primary.log") + log := New("info", primary, DiagnosticOptions{Dir: filepath.Join(primary, "invalid")}) + log.Error("still visible") + data, err := os.ReadFile(primary) + if err != nil || !strings.Contains(string(data), "still visible") { + t.Fatalf("primary output lost: %s, %v", data, err) + } +}