package debugtrace import ( "bufio" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "sync" "time" ) type contextKey struct{} type Config struct { Enabled bool `json:"enabled"` Dir string `json:"dir"` MaxBytes int64 `json:"maxBytes"` KeepFiles int `json:"keepFiles"` MaxString int `json:"maxString"` LogHTTPState bool `json:"logHttpState"` } type Event struct { Schema string `json:"schema"` Timestamp string `json:"timestamp"` TraceID string `json:"trace_id"` Component string `json:"component"` Stage string `json:"stage"` Direction string `json:"direction,omitempty"` Level string `json:"level,omitempty"` DurationMS int64 `json:"duration_ms,omitempty"` Data any `json:"data,omitempty"` Error string `json:"error,omitempty"` } type Logger struct { mu sync.Mutex cfg Config path string file *os.File size int64 } func New(cfg Config) (*Logger, error) { if cfg.Dir == "" { cfg.Dir = "./data/debug" } if cfg.MaxBytes <= 0 { cfg.MaxBytes = 25 << 20 } if cfg.KeepFiles <= 0 { cfg.KeepFiles = 4 } if cfg.MaxString <= 0 { cfg.MaxString = 24000 } l := &Logger{cfg: cfg, path: filepath.Join(cfg.Dir, "jarvis-debug.jsonl")} if !cfg.Enabled { return l, nil } if err := os.MkdirAll(cfg.Dir, 0o755); err != nil { return nil, err } f, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return nil, err } l.file = f if st, err := f.Stat(); err == nil { l.size = st.Size() } return l, nil } func (l *Logger) Config() Config { return l.cfg } func (l *Logger) Enabled() bool { return l != nil && l.cfg.Enabled } func NewTraceID(prefix string) string { if strings.TrimSpace(prefix) == "" { prefix = "trace" } var b [6]byte if _, err := rand.Read(b[:]); err == nil { return fmt.Sprintf("%s_%d_%s", prefix, time.Now().UnixMilli(), hex.EncodeToString(b[:])) } return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) } func WithTrace(ctx context.Context, traceID string) context.Context { if strings.TrimSpace(traceID) == "" { traceID = NewTraceID("trace") } return context.WithValue(ctx, contextKey{}, traceID) } func TraceID(ctx context.Context) string { if ctx == nil { return "" } if v, ok := ctx.Value(contextKey{}).(string); ok { return v } return "" } func EnsureTrace(ctx context.Context, prefix string) (context.Context, string) { if id := TraceID(ctx); id != "" { return ctx, id } id := NewTraceID(prefix) return WithTrace(ctx, id), id } func (l *Logger) Record(ctx context.Context, component, stage, direction, level string, data any, err error, duration time.Duration) { if !l.Enabled() { return } traceID := TraceID(ctx) if traceID == "" { traceID = NewTraceID("bg") } ev := Event{Schema: "jarvis.debug.v1", Timestamp: time.Now().Format(time.RFC3339Nano), TraceID: traceID, Component: component, Stage: stage, Direction: direction, Level: level, Data: l.sanitize(data)} if err != nil { ev.Error = err.Error() } if duration > 0 { ev.DurationMS = duration.Milliseconds() } b, merr := json.Marshal(ev) if merr != nil { return } l.mu.Lock() defer l.mu.Unlock() if l.file == nil { return } if l.size+int64(len(b)+1) > l.cfg.MaxBytes { _ = l.rotateLocked() } n, werr := l.file.Write(append(b, '\n')) if werr == nil { l.size += int64(n) } } func (l *Logger) rotateLocked() error { if l.file != nil { _ = l.file.Close() l.file = nil } for i := l.cfg.KeepFiles - 1; i >= 1; i-- { old := fmt.Sprintf("%s.%d", l.path, i) next := fmt.Sprintf("%s.%d", l.path, i+1) if i == l.cfg.KeepFiles-1 { _ = os.Remove(next) } _ = os.Rename(old, next) } _ = os.Rename(l.path, l.path+".1") f, err := os.OpenFile(l.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } l.file = f l.size = 0 return nil } func (l *Logger) sanitize(v any) any { b, err := json.Marshal(v) if err != nil { return fmt.Sprint(v) } var x any if err := json.Unmarshal(b, &x); err != nil { return string(b) } return sanitizeValue(x, l.cfg.MaxString) } func sanitizeValue(v any, max int) any { switch x := v.(type) { case map[string]any: out := make(map[string]any, len(x)) for k, val := range x { lk := strings.ToLower(k) if secretKey(lk) { out[k] = "[REDACTED]" } else { out[k] = sanitizeValue(val, max) } } return out case []any: out := make([]any, len(x)) for i := range x { out[i] = sanitizeValue(x[i], max) } return out case string: if max > 0 && len([]rune(x)) > max { r := []rune(x) return string(r[:max]) + fmt.Sprintf("\n[… %d chars truncated …]", len(r)-max) } return x default: return v } } func secretKey(k string) bool { for _, s := range []string{"password", "passwd", "authorization", "api_key", "apikey", "secret", "token", "bearer", "cookie"} { if strings.Contains(k, s) { return true } } return false } func (l *Logger) Clear() error { if !l.Enabled() { return nil } l.mu.Lock() defer l.mu.Unlock() if l.file != nil { _ = l.file.Close() l.file = nil } matches, _ := filepath.Glob(filepath.Join(l.cfg.Dir, "jarvis-debug.jsonl*")) for _, p := range matches { _ = os.Remove(p) } f, err := os.OpenFile(l.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } l.file = f l.size = 0 return nil } func (l *Logger) Events(limit int) ([]json.RawMessage, error) { if !l.Enabled() { return []json.RawMessage{}, nil } l.mu.Lock() if l.file != nil { _ = l.file.Sync() } l.mu.Unlock() paths := []string{} for i := l.cfg.KeepFiles; i >= 1; i-- { p := fmt.Sprintf("%s.%d", l.path, i) if _, err := os.Stat(p); err == nil { paths = append(paths, p) } } if _, err := os.Stat(l.path); err == nil { paths = append(paths, l.path) } events := []json.RawMessage{} for _, p := range paths { f, err := os.Open(p) if err != nil { continue } sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 64*1024), 4<<20) for sc.Scan() { line := append([]byte(nil), sc.Bytes()...) if json.Valid(line) { events = append(events, json.RawMessage(line)) } } _ = f.Close() } if limit > 0 && len(events) > limit { events = events[len(events)-limit:] } return events, nil } func (l *Logger) WriteJSONL(w io.Writer, limit int) error { events, err := l.Events(limit) if err != nil { return err } for _, e := range events { if _, err := w.Write(append(e, '\n')); err != nil { return err } } return nil } func (l *Logger) Files() ([]string, error) { if !l.Enabled() { return nil, nil } matches, err := filepath.Glob(filepath.Join(l.cfg.Dir, "jarvis-debug.jsonl*")) if err != nil { return nil, err } sort.Strings(matches) return matches, nil } var ErrDisabled = errors.New("debug tracing disabled")