mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-14 13:48:56 +02:00
feat: persist diagnostic logs with daily rotation and retention
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user