Sync and async sinks, redaction, and a queryable HTTP service keep concurrent logs tied to a trace id. Co-authored-by: Cursor <cursoragent@cursor.com>
135 lines
3.4 KiB
Go
135 lines
3.4 KiB
Go
package logsys
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type memSink struct {
|
|
mu sync.Mutex
|
|
entries []Entry
|
|
}
|
|
|
|
func (m *memSink) Write(e Entry) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.entries = append(m.entries, e)
|
|
return nil
|
|
}
|
|
func (m *memSink) Sync() error { return nil }
|
|
func (m *memSink) Close() error { return nil }
|
|
|
|
func (m *memSink) list() []Entry {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]Entry, len(m.entries))
|
|
copy(out, m.entries)
|
|
return out
|
|
}
|
|
|
|
type stubErr struct{}
|
|
|
|
func (stubErr) Error() string { return "dial failed" }
|
|
func (stubErr) ErrorCode() string { return "agent.provider_unavailable" }
|
|
func (stubErr) ErrorFields() map[string]any {
|
|
return map[string]any{"model": "glm-4", "reason": "模型不可达"}
|
|
}
|
|
|
|
func TestLevelFilterModuleAndTrace(t *testing.T) {
|
|
mem := &memSink{}
|
|
fixed := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
|
|
log := New(Options{
|
|
Service: "agent-squad",
|
|
Level: LevelInfo,
|
|
ModuleLevels: map[string]Level{
|
|
"engine": LevelDebug,
|
|
"server": LevelWarn,
|
|
},
|
|
Sinks: []Sink{mem},
|
|
Now: func() time.Time { return fixed },
|
|
})
|
|
log.Debug("hidden")
|
|
log.Info("visible")
|
|
log.Module("engine").Debug("engine-debug")
|
|
log.Module("server").Info("server-info")
|
|
log.Module("server").Warn("server-warn")
|
|
ctx := WithTrace(context.Background(), "trace-1")
|
|
log.WithContext(ctx).With("step", "plan").Info("traced")
|
|
|
|
got := mem.list()
|
|
if len(got) != 4 {
|
|
t.Fatalf("len %d %+v", len(got), got)
|
|
}
|
|
if got[0].Message != "visible" || got[0].Time != fixed || got[0].Service != "agent-squad" {
|
|
t.Fatalf("first %+v", got[0])
|
|
}
|
|
if got[1].Module != "engine" || got[1].Message != "engine-debug" {
|
|
t.Fatalf("engine %+v", got[1])
|
|
}
|
|
if got[2].Message != "server-warn" {
|
|
t.Fatalf("server %+v", got[2])
|
|
}
|
|
if got[3].TraceID != "trace-1" || got[3].Fields["step"] != "plan" {
|
|
t.Fatalf("trace %+v", got[3])
|
|
}
|
|
}
|
|
|
|
func TestErrorDuckTypingAndRedact(t *testing.T) {
|
|
mem := &memSink{}
|
|
log := New(Options{
|
|
Level: LevelInfo,
|
|
Sinks: []Sink{mem},
|
|
Redact: func(s string) string { return strings.ReplaceAll(s, "glm-4", "***") },
|
|
})
|
|
log.Error("provider down", stubErr{}, "attempt", 2)
|
|
got := mem.list()
|
|
if len(got) != 1 {
|
|
t.Fatal(len(got))
|
|
}
|
|
e := got[0]
|
|
if e.Code != "agent.provider_unavailable" || e.Level != "error" {
|
|
t.Fatalf("%+v", e)
|
|
}
|
|
if e.Fields["model"] != "***" || e.Fields["reason"] != "模型不可达" || e.Fields["attempt"] != 2 {
|
|
t.Fatalf("fields %+v", e.Fields)
|
|
}
|
|
if !strings.Contains(e.Error, "dial failed") {
|
|
t.Fatal(e.Error)
|
|
}
|
|
plain := errors.New("boom")
|
|
log.Error("plain", plain)
|
|
if mem.list()[1].Code != "" || mem.list()[1].Error != "boom" {
|
|
t.Fatalf("%+v", mem.list()[1])
|
|
}
|
|
}
|
|
|
|
func TestDefaultLogger(t *testing.T) {
|
|
prev := Default()
|
|
t.Cleanup(func() { SetDefault(prev) })
|
|
mem := &memSink{}
|
|
SetDefault(New(Options{Level: LevelInfo, Sinks: []Sink{mem}}))
|
|
Default().Info("from-default")
|
|
if err := Sync(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(mem.list()) != 1 {
|
|
t.Fatal(mem.list())
|
|
}
|
|
}
|
|
|
|
func TestHumanAndJSON(t *testing.T) {
|
|
e := Entry{Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Level: "info", Service: "svc", Message: "hello", Fields: map[string]any{"b": 1, "a": "x"}}
|
|
line := FormatHuman(e)
|
|
if !strings.Contains(line, "INFO svc hello") || !strings.Contains(line, "a=x") {
|
|
t.Fatal(line)
|
|
}
|
|
raw, err := FormatJSON(e)
|
|
if err != nil || !strings.Contains(string(raw), `"msg":"hello"`) {
|
|
t.Fatalf("%s %v", raw, err)
|
|
}
|
|
}
|