Files
2026-08-29 17:02:35 +02:00

315 lines
6.8 KiB
Go

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")