From b2fd12b1c841630d32834d80831e1cffc8e2289a Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 25 Apr 2026 20:35:03 +0000 Subject: [PATCH] restructured logger from budigt --- README.md | 118 ++++++++++++++++++- console_stream.go | 75 ++++++++++++ console_stream_test.go | 52 +++++++++ file_rotation.go | 113 ++++++++++++++++++ file_stream.go | 166 +++++++++++++++++++++++++++ file_stream_test.go | 252 +++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + logger.go | 124 ++++++++++++++++++++ logger_test.go | 219 +++++++++++++++++++++++++++++++++++ splinter.go | 41 +++++++ stream.go | 65 +++++++++++ 11 files changed, 1227 insertions(+), 1 deletion(-) create mode 100644 console_stream.go create mode 100644 console_stream_test.go create mode 100644 file_rotation.go create mode 100644 file_stream.go create mode 100644 file_stream_test.go create mode 100644 go.mod create mode 100644 logger.go create mode 100644 logger_test.go create mode 100644 splinter.go create mode 100644 stream.go diff --git a/README.md b/README.md index 13cbe20..8390fc4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,119 @@ # splinter -Simple and minimal logger in Go for my projects. \ No newline at end of file +Simple and minimal logger in Go for my projects. + +Fans out structured records to one or more streams. Ships with a console +stream (JSON or pretty text) and a file stream with size- and time-based +rotation, optional gzip compression, and backup pruning. + +## Install + +```sh +go get git.juancwu.dev/juancwu/splinter +``` + +Requires Go 1.26 or newer. + +## Quick start + +The package exposes a default logger that writes JSON to stderr at +`LevelInfo`: + +```go +package main + +import "git.juancwu.dev/juancwu/splinter" + +func main() { + splinter.Info("server started", "port", 8080) + splinter.Error("request failed", "err", "timeout") +} +``` + +## Custom logger with file output and rotation + +```go +package main + +import ( + "time" + + "git.juancwu.dev/juancwu/splinter" +) + +func main() { + logger := splinter.New( + splinter.WithStream(splinter.NewConsoleStream( + splinter.ConsolePretty, splinter.LevelDebug, + )), + splinter.WithStream(splinter.MustFileStream("logs/app.log", splinter.FileStreamConfig{ + Level: splinter.LevelInfo, + Format: splinter.FileJSON, + MaxSizeMB: 100, // rotate at 100 MB + MaxAge: 24 * time.Hour, // and at least once per day + MaxBackups: 7, // keep the 7 most recent files + Compress: true, // gzip rotated backups + })), + splinter.WithAttrs(map[string]any{"service": "api"}), + ) + defer logger.Close() + + splinter.SetDefault(logger) + + splinter.Info("ready") +} +``` + +## Child loggers + +`Logger.With` returns a child that inherits streams and merges additional +attributes onto every record: + +```go +req := logger.With(map[string]any{"request_id": id}) +req.Info("handled", "status", 200) +``` + +## Custom streams + +Implement the `Stream` interface to send logs anywhere — a database, a +message queue, an HTTP endpoint, an in-memory buffer for tests: + +```go +type Stream interface { + Name() string + Write(ctx context.Context, rec splinter.Record) error + Enabled(level splinter.Level) bool + Close() error +} +``` + +Add it with `splinter.WithStream(myStream)` at construction time. + +## Configuration reference + +### `ConsoleStream` + +| Constructor | Behaviour | +| ----------------------------------------- | -------------------------------------- | +| `NewConsoleStream(ConsoleJSON, level)` | One JSON object per line on stderr. | +| `NewConsoleStream(ConsolePretty, level)` | `key=value` text on stderr. | +| `ConsoleWriter(w)` option | Override the destination writer. | + +### `FileStream` + +| Field | Default | Notes | +| ------------ | ----------- | -------------------------------------------------------------- | +| `Level` | `LevelInfo` | Minimum level written to the file. | +| `Format` | `FileJSON` | `FileJSON` or `FileText`. | +| `MaxSizeMB` | `100` | Rotation threshold in MB. `0` disables size-based rotation. | +| `MaxAge` | `0` | Rotation threshold by age. `0` disables time-based rotation. | +| `MaxBackups` | `5` | Number of rotated files to retain (raw + `.gz` combined). | +| `Compress` | `false` | gzip rotated files asynchronously after rename. | + +When both `MaxSizeMB` and `MaxAge` are zero, `MaxSizeMB` defaults to `100` +to prevent unbounded growth. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/console_stream.go b/console_stream.go new file mode 100644 index 0000000..d15f494 --- /dev/null +++ b/console_stream.go @@ -0,0 +1,75 @@ +package splinter + +import ( + "context" + "io" + "log/slog" + "os" +) + +// ConsoleFormat selects the output style for ConsoleStream. +type ConsoleFormat int + +const ( + // ConsoleJSON writes structured JSON lines. + ConsoleJSON ConsoleFormat = iota + // ConsolePretty writes human-readable text (slog.TextHandler). + ConsolePretty +) + +// ConsoleStream writes log records to an io.Writer (default os.Stderr) using +// the standard library's slog handlers. +type ConsoleStream struct { + logger *slog.Logger + level Level +} + +// ConsoleOption configures a ConsoleStream. +type ConsoleOption func(*consoleConfig) + +type consoleConfig struct { + writer io.Writer +} + +// ConsoleWriter overrides the output destination (default: os.Stderr). +func ConsoleWriter(w io.Writer) ConsoleOption { + return func(c *consoleConfig) { c.writer = w } +} + +// NewConsoleStream creates a console stream. +func NewConsoleStream(format ConsoleFormat, level Level, opts ...ConsoleOption) *ConsoleStream { + cfg := &consoleConfig{writer: os.Stderr} + for _, o := range opts { + o(cfg) + } + + handlerOpts := &slog.HandlerOptions{Level: level} + + var handler slog.Handler + switch format { + case ConsolePretty: + handler = slog.NewTextHandler(cfg.writer, handlerOpts) + default: + handler = slog.NewJSONHandler(cfg.writer, handlerOpts) + } + return &ConsoleStream{logger: slog.New(handler), level: level} +} + +// Name implements Stream. +func (s *ConsoleStream) Name() string { return "console" } + +// Write implements Stream. +func (s *ConsoleStream) Write(ctx context.Context, rec Record) error { + args := make([]any, 0, len(rec.Attrs)*2) + for k, v := range rec.Attrs { + args = append(args, k, v) + } + s.logger.Log(ctx, rec.Level, rec.Message, args...) + return nil +} + +// Enabled implements Stream. +func (s *ConsoleStream) Enabled(level Level) bool { return level >= s.level } + +// Close implements Stream. No-op for console output. +func (s *ConsoleStream) Close() error { return nil } diff --git a/console_stream_test.go b/console_stream_test.go new file mode 100644 index 0000000..d6f7fd0 --- /dev/null +++ b/console_stream_test.go @@ -0,0 +1,52 @@ +package splinter + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestConsoleStream_JSON(t *testing.T) { + var buf bytes.Buffer + logger := New(WithStream(NewConsoleStream(ConsoleJSON, LevelDebug, ConsoleWriter(&buf)))) + logger.Info("hello", "key", "value") + + var entry map[string]any + if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { + t.Fatalf("parse JSON: %v\nbody: %s", err, buf.String()) + } + if entry["msg"] != "hello" { + t.Errorf("expected msg=hello, got %v", entry["msg"]) + } + if entry["key"] != "value" { + t.Errorf("expected key=value, got %v", entry["key"]) + } +} + +func TestConsoleStream_Pretty(t *testing.T) { + var buf bytes.Buffer + logger := New(WithStream(NewConsoleStream(ConsolePretty, LevelDebug, ConsoleWriter(&buf)))) + logger.Info("pretty test", "foo", "bar") + + if buf.Len() == 0 { + t.Fatal("expected text output, got nothing") + } + if bytes.Contains(buf.Bytes(), []byte("{")) { + t.Errorf("expected text format, got JSON-like output: %s", buf.String()) + } + if !bytes.Contains(buf.Bytes(), []byte("foo=bar")) { + t.Errorf("expected foo=bar in output: %s", buf.String()) + } +} + +func TestConsoleStream_LevelFilter(t *testing.T) { + var buf bytes.Buffer + logger := New(WithStream(NewConsoleStream(ConsoleJSON, LevelError, ConsoleWriter(&buf)))) + logger.Info("skipped") + logger.Error("kept") + + lines := bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) + if len(lines) != 1 { + t.Fatalf("expected 1 line, got %d: %s", len(lines), buf.String()) + } +} diff --git a/file_rotation.go b/file_rotation.go new file mode 100644 index 0000000..8999a7c --- /dev/null +++ b/file_rotation.go @@ -0,0 +1,113 @@ +package splinter + +import ( + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" +) + +const rotationTimestampFormat = "20060102T150405.000000000Z" + +// shouldRotate reports whether the current file has hit a rotation trigger. +// Caller must hold s.mu. +func (s *FileStream) shouldRotate() bool { + if s.cfg.MaxSizeMB > 0 && s.counter.n >= int64(s.cfg.MaxSizeMB)*1024*1024 { + return true + } + if s.cfg.MaxAge > 0 && time.Since(s.openedAt) >= s.cfg.MaxAge { + return true + } + return false +} + +// rotate closes the current file, renames it to a timestamped backup, opens +// a fresh file, and kicks off async compression + pruning. Caller holds s.mu. +func (s *FileStream) rotate() error { + if err := s.file.Close(); err != nil { + return err + } + + ts := time.Now().UTC().Format(rotationTimestampFormat) + ext := filepath.Ext(s.path) + base := s.path[:len(s.path)-len(ext)] + backupPath := fmt.Sprintf("%s.%s%s", base, ts, ext) + + if err := os.Rename(s.path, backupPath); err != nil { + return err + } + + f, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + s.attach(f, 0) + s.openedAt = time.Now() + + go s.compressAndPrune(backupPath, base, ext) + return nil +} + +// compressAndPrune runs in the background after rotation: gzips the new +// backup (if configured) and deletes any stale backups beyond MaxBackups. +// Errors are swallowed since this is best-effort housekeeping. +func (s *FileStream) compressAndPrune(backupPath, base, ext string) { + if s.cfg.Compress { + _ = gzipFile(backupPath) + } + s.pruneBackups(base, ext) +} + +// gzipFile compresses src to src+".gz", then removes src on success. +func gzipFile(src string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(src+".gz", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return err + } + gz := gzip.NewWriter(out) + + if _, err := io.Copy(gz, in); err != nil { + gz.Close() + out.Close() + os.Remove(src + ".gz") + return err + } + if err := gz.Close(); err != nil { + out.Close() + os.Remove(src + ".gz") + return err + } + if err := out.Close(); err != nil { + os.Remove(src + ".gz") + return err + } + return os.Remove(src) +} + +// pruneBackups removes the oldest rotated files when the total exceeds +// MaxBackups. Both raw and gzipped backups are considered. +func (s *FileStream) pruneBackups(base, ext string) { + raw, _ := filepath.Glob(fmt.Sprintf("%s.*%s", base, ext)) + gz, _ := filepath.Glob(fmt.Sprintf("%s.*%s.gz", base, ext)) + + all := append(raw, gz...) + if len(all) <= s.cfg.MaxBackups { + return + } + // Lex sort puts older timestamps first; trailing ".gz" sorts after the + // raw form, so any transient duplicate during compression is handled + // naturally on the next rotation. + sort.Strings(all) + for _, p := range all[:len(all)-s.cfg.MaxBackups] { + _ = os.Remove(p) + } +} diff --git a/file_stream.go b/file_stream.go new file mode 100644 index 0000000..822e604 --- /dev/null +++ b/file_stream.go @@ -0,0 +1,166 @@ +package splinter + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sync" + "time" +) + +// FileFormat selects the on-disk encoding for FileStream. +type FileFormat int + +const ( + // FileJSON writes one JSON object per line (slog.JSONHandler). + FileJSON FileFormat = iota + // FileText writes a human-readable text line (slog.TextHandler). + FileText +) + +// FileStreamConfig controls FileStream behaviour. +type FileStreamConfig struct { + // Level is the minimum level to write. Default: LevelInfo. + Level Level + + // Format is the on-disk encoding. Default: FileJSON. + Format FileFormat + + // MaxSizeMB is the file-size rotation threshold in megabytes. + // Zero disables size-based rotation. When both MaxSizeMB and MaxAge are + // zero, MaxSizeMB defaults to 100 so logs cannot grow unbounded. + MaxSizeMB int + + // MaxAge is the age-based rotation threshold. Zero disables it. + // Rotation fires lazily on the first Write after the boundary elapses. + MaxAge time.Duration + + // MaxBackups is the number of rotated files to retain. Default: 5. + MaxBackups int + + // Compress gzips rotated files asynchronously after rotation. + Compress bool +} + +func (c *FileStreamConfig) defaults() { + if c.MaxSizeMB == 0 && c.MaxAge == 0 { + c.MaxSizeMB = 100 + } + if c.MaxBackups <= 0 { + c.MaxBackups = 5 + } +} + +// FileStream writes log records to a file with size and/or time-based +// rotation. Safe for concurrent use. +type FileStream struct { + mu sync.Mutex + file *os.File + counter *countingWriter + handler slog.Handler + path string + cfg FileStreamConfig + openedAt time.Time +} + +// NewFileStream opens (or creates) the log file and returns a ready stream. +func NewFileStream(path string, cfg FileStreamConfig) (*FileStream, error) { + cfg.defaults() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("splinter: create dir: %w", err) + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("splinter: open %s: %w", path, err) + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, fmt.Errorf("splinter: stat %s: %w", path, err) + } + + s := &FileStream{ + path: path, + cfg: cfg, + openedAt: time.Now(), + } + s.attach(f, info.Size()) + return s, nil +} + +// MustFileStream is like NewFileStream but panics on error. Convenient for +// application startup where a missing log file is fatal. +func MustFileStream(path string, cfg FileStreamConfig) *FileStream { + s, err := NewFileStream(path, cfg) + if err != nil { + panic(err) + } + return s +} + +// attach wires up the file, counting writer, and slog handler. Caller holds +// s.mu (or is in NewFileStream where no concurrency exists yet). +func (s *FileStream) attach(f *os.File, initialSize int64) { + s.file = f + s.counter = &countingWriter{w: f, n: initialSize} + + opts := &slog.HandlerOptions{Level: s.cfg.Level} + switch s.cfg.Format { + case FileText: + s.handler = slog.NewTextHandler(s.counter, opts) + default: + s.handler = slog.NewJSONHandler(s.counter, opts) + } +} + +// Name implements Stream. +func (s *FileStream) Name() string { return "file:" + s.path } + +// Enabled implements Stream. +func (s *FileStream) Enabled(level Level) bool { return level >= s.cfg.Level } + +// Write implements Stream. +func (s *FileStream) Write(ctx context.Context, rec Record) error { + sr := slog.NewRecord(rec.Time, rec.Level, rec.Message, 0) + for k, v := range rec.Attrs { + sr.Add(k, v) + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.handler.Handle(ctx, sr); err != nil { + return fmt.Errorf("splinter: file write: %w", err) + } + if s.shouldRotate() { + if err := s.rotate(); err != nil { + return fmt.Errorf("splinter: rotate: %w", err) + } + } + return nil +} + +// Close implements Stream. Flushes and closes the underlying file. +func (s *FileStream) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.file.Close() +} + +// countingWriter wraps an io.Writer and tracks cumulative bytes written so +// FileStream can make accurate size-based rotation decisions. +type countingWriter struct { + w io.Writer + n int64 +} + +func (c *countingWriter) Write(p []byte) (int, error) { + n, err := c.w.Write(p) + c.n += int64(n) + return n, err +} diff --git a/file_stream_test.go b/file_stream_test.go new file mode 100644 index 0000000..8fe2a3d --- /dev/null +++ b/file_stream_test.go @@ -0,0 +1,252 @@ +package splinter + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestFileStream_WritesJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.log") + fs, err := NewFileStream(path, FileStreamConfig{Level: LevelDebug, Format: FileJSON}) + if err != nil { + t.Fatalf("NewFileStream: %v", err) + } + + logger := New(WithStream(fs)) + logger.Info("file test", "key", "val") + if err := fs.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + var entry map[string]any + if err := json.Unmarshal(bytes.TrimSpace(data), &entry); err != nil { + t.Fatalf("parse: %v\nbody: %s", err, data) + } + if entry["msg"] != "file test" { + t.Errorf("expected msg=file test, got %v", entry["msg"]) + } + if entry["key"] != "val" { + t.Errorf("expected key=val, got %v", entry["key"]) + } +} + +func TestFileStream_WritesText(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.log") + fs, err := NewFileStream(path, FileStreamConfig{Level: LevelDebug, Format: FileText}) + if err != nil { + t.Fatalf("NewFileStream: %v", err) + } + + logger := New(WithStream(fs)) + logger.Info("plain", "key", "val") + if err := fs.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if bytes.Contains(data, []byte("{")) { + t.Errorf("expected text format, got JSON-like output: %s", data) + } + if !bytes.Contains(data, []byte("key=val")) { + t.Errorf("expected key=val in output: %s", data) + } +} + +func TestFileStream_LevelFilter(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.log") + fs, err := NewFileStream(path, FileStreamConfig{Level: LevelError}) + if err != nil { + t.Fatalf("NewFileStream: %v", err) + } + + logger := New(WithStream(fs)) + logger.Info("skip") + logger.Error("keep") + if err := fs.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + lines := bytes.Split(bytes.TrimSpace(data), []byte("\n")) + if len(lines) != 1 { + t.Errorf("expected 1 line, got %d", len(lines)) + } +} + +func TestFileStream_RotatesOnSize(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + fs, err := NewFileStream(path, FileStreamConfig{ + Level: LevelDebug, + MaxSizeMB: 1, + MaxBackups: 5, + }) + if err != nil { + t.Fatalf("NewFileStream: %v", err) + } + defer fs.Close() + + big := strings.Repeat("x", 1024*1024+1) + logger := New(WithStream(fs)) + logger.Info("trigger", "blob", big) + + backups, err := filepath.Glob(filepath.Join(dir, "app.*.log")) + if err != nil { + t.Fatalf("glob: %v", err) + } + if len(backups) != 1 { + t.Fatalf("expected 1 backup after size rotation, got %d (%v)", len(backups), backups) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("expected fresh app.log to exist: %v", err) + } +} + +func TestFileStream_RotatesOnAge(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + fs, err := NewFileStream(path, FileStreamConfig{ + Level: LevelDebug, + MaxAge: 30 * time.Millisecond, + MaxBackups: 5, + }) + if err != nil { + t.Fatalf("NewFileStream: %v", err) + } + defer fs.Close() + + logger := New(WithStream(fs)) + logger.Info("first") + + time.Sleep(60 * time.Millisecond) + logger.Info("second") + + backups, err := filepath.Glob(filepath.Join(dir, "app.*.log")) + if err != nil { + t.Fatalf("glob: %v", err) + } + if len(backups) != 1 { + t.Fatalf("expected 1 backup after age rotation, got %d (%v)", len(backups), backups) + } +} + +func TestFileStream_CompressOnRotation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + fs, err := NewFileStream(path, FileStreamConfig{ + Level: LevelDebug, + MaxAge: 20 * time.Millisecond, + MaxBackups: 5, + Compress: true, + }) + if err != nil { + t.Fatalf("NewFileStream: %v", err) + } + defer fs.Close() + + logger := New(WithStream(fs)) + logger.Info("first", "k", "v") + + time.Sleep(40 * time.Millisecond) + logger.Info("second", "k", "v") + + gzPaths := waitForFiles(t, filepath.Join(dir, "app.*.log.gz"), 1, 2*time.Second) + + rawPaths, _ := filepath.Glob(filepath.Join(dir, "app.*.log")) + if len(rawPaths) != 0 { + t.Errorf("expected raw .log backups to be removed after gzip, got %v", rawPaths) + } + + gzFile, err := os.Open(gzPaths[0]) + if err != nil { + t.Fatalf("open gz: %v", err) + } + defer gzFile.Close() + gr, err := gzip.NewReader(gzFile) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + body, err := io.ReadAll(gr) + if err != nil { + t.Fatalf("read gz: %v", err) + } + if !bytes.Contains(body, []byte(`"first"`)) { + t.Errorf("expected gzipped backup to contain first message, got %s", body) + } +} + +func TestFileStream_PruneRespectsMaxBackups(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + fs, err := NewFileStream(path, FileStreamConfig{ + Level: LevelDebug, + MaxAge: 5 * time.Millisecond, + MaxBackups: 2, + }) + if err != nil { + t.Fatalf("NewFileStream: %v", err) + } + defer fs.Close() + + logger := New(WithStream(fs)) + for i := 0; i < 5; i++ { + logger.Info("rotate") + // Sleep longer than MaxAge so the next write triggers rotation, plus + // enough nanoseconds to guarantee a unique timestamp suffix. + time.Sleep(15 * time.Millisecond) + } + // Final write that flushes the last rotation through to disk. + logger.Info("final") + + // Wait for prune goroutines to settle. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + backups, _ := filepath.Glob(filepath.Join(dir, "app.*.log")) + if len(backups) <= 2 { + break + } + time.Sleep(10 * time.Millisecond) + } + + backups, _ := filepath.Glob(filepath.Join(dir, "app.*.log")) + if len(backups) > 2 { + t.Errorf("expected at most 2 backups, got %d (%v)", len(backups), backups) + } +} + +// waitForFiles polls the glob pattern until it matches at least `want` +// entries or the deadline expires. Returns the matched paths. +func waitForFiles(t *testing.T, pattern string, want int, timeout time.Duration) []string { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + matches, err := filepath.Glob(pattern) + if err != nil { + t.Fatalf("glob %s: %v", pattern, err) + } + if len(matches) >= want { + return matches + } + time.Sleep(10 * time.Millisecond) + } + matches, _ := filepath.Glob(pattern) + t.Fatalf("timed out waiting for %d files matching %s (got %d: %v)", want, pattern, len(matches), matches) + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e56844 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.juancwu.dev/juancwu/splinter + +go 1.26.2 diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..f568e87 --- /dev/null +++ b/logger.go @@ -0,0 +1,124 @@ +package splinter + +import ( + "context" + "fmt" + "maps" + "os" + "time" +) + +// Logger fans out log records to one or more Streams. Streams are fixed at +// construction time; child loggers from With share the same streams. +type Logger struct { + streams []Stream + baseAttrs map[string]any + onError func(stream string, rec Record, err error) +} + +// Option configures a Logger. +type Option func(*Logger) + +// WithStream adds a Stream to the logger. +func WithStream(s Stream) Option { + return func(l *Logger) { l.streams = append(l.streams, s) } +} + +// WithAttrs pre-populates attributes on every record produced by this logger. +func WithAttrs(attrs map[string]any) Option { + return func(l *Logger) { maps.Copy(l.baseAttrs, attrs) } +} + +// WithErrorHandler sets a callback invoked when a stream's Write fails. +// The default handler prints to stderr. +func WithErrorHandler(fn func(stream string, rec Record, err error)) Option { + return func(l *Logger) { l.onError = fn } +} + +// New creates a Logger. With no streams supplied, a JSON ConsoleStream at +// LevelInfo writing to stderr is used. +func New(opts ...Option) *Logger { + l := &Logger{ + baseAttrs: make(map[string]any), + onError: defaultOnError, + } + for _, o := range opts { + o(l) + } + if len(l.streams) == 0 { + l.streams = append(l.streams, NewConsoleStream(ConsoleJSON, LevelInfo)) + } + return l +} + +// With returns a child Logger with additional attributes merged onto the +// base. The child shares streams and the error handler with the parent. +func (l *Logger) With(attrs map[string]any) *Logger { + merged := make(map[string]any, len(l.baseAttrs)+len(attrs)) + maps.Copy(merged, l.baseAttrs) + maps.Copy(merged, attrs) + return &Logger{ + streams: l.streams, + baseAttrs: merged, + onError: l.onError, + } +} + +// Debug logs at LevelDebug. +func (l *Logger) Debug(msg string, args ...any) { l.log(LevelDebug, msg, args...) } + +// Info logs at LevelInfo. +func (l *Logger) Info(msg string, args ...any) { l.log(LevelInfo, msg, args...) } + +// Warn logs at LevelWarn. +func (l *Logger) Warn(msg string, args ...any) { l.log(LevelWarn, msg, args...) } + +// Error logs at LevelError. +func (l *Logger) Error(msg string, args ...any) { l.log(LevelError, msg, args...) } + +// Close shuts down all streams. Returns the first error encountered, but +// always attempts to close every stream. +func (l *Logger) Close() error { + var firstErr error + for _, s := range l.streams { + if err := s.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (l *Logger) log(level Level, msg string, args ...any) { + rec := Record{ + Time: time.Now(), + Level: level, + Message: msg, + Attrs: l.buildAttrs(args), + } + ctx := context.Background() + for _, s := range l.streams { + if !s.Enabled(level) { + continue + } + if err := s.Write(ctx, rec); err != nil { + l.onError(s.Name(), rec, err) + } + } +} + +func (l *Logger) buildAttrs(args []any) map[string]any { + attrs := make(map[string]any, len(l.baseAttrs)+len(args)/2) + maps.Copy(attrs, l.baseAttrs) + for i := 0; i+1 < len(args); i += 2 { + key, ok := args[i].(string) + if !ok { + continue + } + attrs[key] = args[i+1] + } + return attrs +} + +func defaultOnError(stream string, _ Record, err error) { + fmt.Fprintf(os.Stderr, "splinter: stream %q write failed: %v\n", stream, err) +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..aab1c50 --- /dev/null +++ b/logger_test.go @@ -0,0 +1,219 @@ +package splinter + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "sync" + "testing" +) + +type memoryStream struct { + mu sync.Mutex + records []Record + level Level +} + +func newMemoryStream(level Level) *memoryStream { + return &memoryStream{level: level} +} + +func (m *memoryStream) Name() string { return "memory" } +func (m *memoryStream) Write(_ context.Context, rec Record) error { + m.mu.Lock() + defer m.mu.Unlock() + m.records = append(m.records, rec) + return nil +} +func (m *memoryStream) Enabled(level Level) bool { return level >= m.level } +func (m *memoryStream) Close() error { return nil } + +func (m *memoryStream) len() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.records) +} + +func (m *memoryStream) last() Record { + m.mu.Lock() + defer m.mu.Unlock() + return m.records[len(m.records)-1] +} + +type failingStream struct{} + +func (f *failingStream) Name() string { return "failing" } +func (f *failingStream) Write(_ context.Context, _ Record) error { return errors.New("always fails") } +func (f *failingStream) Enabled(_ Level) bool { return true } +func (f *failingStream) Close() error { return nil } + +func TestLogger_LevelFiltering(t *testing.T) { + mem := newMemoryStream(LevelWarn) + logger := New(WithStream(mem)) + + logger.Debug("d") + logger.Info("i") + logger.Warn("w") + logger.Error("e") + + if mem.len() != 2 { + t.Fatalf("expected 2 records (Warn+Error), got %d", mem.len()) + } +} + +func TestLogger_BaseAttrs(t *testing.T) { + mem := newMemoryStream(LevelDebug) + logger := New(WithStream(mem), WithAttrs(map[string]any{"service": "api"})) + logger.Info("request") + + if mem.last().Attrs["service"] != "api" { + t.Errorf("expected service=api, got %v", mem.last().Attrs["service"]) + } +} + +func TestLogger_With(t *testing.T) { + mem := newMemoryStream(LevelDebug) + logger := New(WithStream(mem)) + child := logger.With(map[string]any{"request_id": "abc"}) + child.Info("handled") + + if mem.last().Attrs["request_id"] != "abc" { + t.Errorf("expected request_id=abc, got %v", mem.last().Attrs["request_id"]) + } +} + +func TestLogger_InlineArgsOverrideBase(t *testing.T) { + mem := newMemoryStream(LevelDebug) + logger := New(WithStream(mem), WithAttrs(map[string]any{"env": "prod"})) + logger.Info("override", "env", "staging") + + if mem.last().Attrs["env"] != "staging" { + t.Errorf("expected staging, got %v", mem.last().Attrs["env"]) + } +} + +func TestLogger_FanOut(t *testing.T) { + a := newMemoryStream(LevelDebug) + b := newMemoryStream(LevelDebug) + logger := New(WithStream(a), WithStream(b)) + logger.Info("fanout") + + if a.len() != 1 || b.len() != 1 { + t.Errorf("expected both streams to receive 1 record, got %d and %d", a.len(), b.len()) + } +} + +func TestLogger_ErrorHandler(t *testing.T) { + var captured string + logger := New( + WithStream(&failingStream{}), + WithErrorHandler(func(stream string, _ Record, err error) { + captured = stream + ": " + err.Error() + }), + ) + logger.Info("trigger") + + if captured != "failing: always fails" { + t.Errorf("expected handler to fire, got %q", captured) + } +} + +func TestLogger_DefaultsToConsoleStream(t *testing.T) { + logger := New() + if len(logger.streams) != 1 { + t.Fatalf("expected 1 default stream, got %d", len(logger.streams)) + } + if _, ok := logger.streams[0].(*ConsoleStream); !ok { + t.Errorf("expected default to be *ConsoleStream, got %T", logger.streams[0]) + } +} + +func TestPackageFuncs_RouteThroughDefault(t *testing.T) { + mem := newMemoryStream(LevelDebug) + prev := SetDefault(New(WithStream(mem))) + defer SetDefault(prev) + + Debug("d") + Info("i") + Warn("w") + Error("e") + + if mem.len() != 4 { + t.Fatalf("expected 4 records via package funcs, got %d", mem.len()) + } +} + +func TestSetDefault_ReturnsPrevious(t *testing.T) { + memA := newMemoryStream(LevelDebug) + memB := newMemoryStream(LevelDebug) + + loggerA := New(WithStream(memA)) + loggerB := New(WithStream(memB)) + + original := SetDefault(loggerA) + t.Cleanup(func() { SetDefault(original) }) + + prev := SetDefault(loggerB) + if prev != loggerA { + t.Errorf("expected SetDefault to return loggerA, got %p", prev) + } + + Info("via B") + if memA.len() != 0 { + t.Errorf("expected memA to be empty after swap, got %d", memA.len()) + } + if memB.len() != 1 { + t.Errorf("expected memB to have 1 record, got %d", memB.len()) + } +} + +func TestLevelFromString(t *testing.T) { + tests := []struct { + in string + want Level + }{ + {"debug", LevelDebug}, + {"DEBUG", LevelDebug}, + {"info", LevelInfo}, + {"warn", LevelWarn}, + {"error", LevelError}, + {"bogus", LevelInfo}, + } + for _, tt := range tests { + if got := LevelFromString(tt.in); got != tt.want { + t.Errorf("LevelFromString(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestRecord_LevelLabel(t *testing.T) { + tests := []struct { + level Level + want string + }{ + {LevelDebug, "DEBUG"}, + {LevelInfo, "INFO"}, + {LevelWarn, "WARN"}, + {LevelError, "ERROR"}, + } + for _, tt := range tests { + r := Record{Level: tt.level} + if got := r.LevelLabel(); got != tt.want { + t.Errorf("LevelLabel(%v) = %q, want %q", tt.level, got, tt.want) + } + } +} + +// Sanity: serialising a Record through encoding/json works for callers who +// build their own streams from scratch. +func TestRecord_JSONShape(t *testing.T) { + r := Record{Message: "hi", Attrs: map[string]any{"k": 1}} + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(r); err != nil { + t.Fatalf("encode: %v", err) + } + if !bytes.Contains(buf.Bytes(), []byte(`"Message":"hi"`)) { + t.Errorf("missing Message in JSON: %s", buf.String()) + } +} diff --git a/splinter.go b/splinter.go new file mode 100644 index 0000000..cd6df5a --- /dev/null +++ b/splinter.go @@ -0,0 +1,41 @@ +// Package splinter is a small, opinionated logger that fans out structured +// records to one or more streams (console, file, custom). The default +// package-level logger writes JSON to stderr at LevelInfo; replace it with +// SetDefault to wire in your own configuration. +package splinter + +import "sync" + +var ( + defaultMu sync.RWMutex + def = New(WithStream(NewConsoleStream(ConsoleJSON, LevelInfo))) +) + +// Default returns the package-level Logger. +func Default() *Logger { + defaultMu.RLock() + defer defaultMu.RUnlock() + return def +} + +// SetDefault replaces the package-level Logger. The previous Logger is +// returned so the caller can Close it if they own its streams. +func SetDefault(l *Logger) *Logger { + defaultMu.Lock() + prev := def + def = l + defaultMu.Unlock() + return prev +} + +// Debug logs at LevelDebug on the default Logger. +func Debug(msg string, args ...any) { Default().Debug(msg, args...) } + +// Info logs at LevelInfo on the default Logger. +func Info(msg string, args ...any) { Default().Info(msg, args...) } + +// Warn logs at LevelWarn on the default Logger. +func Warn(msg string, args ...any) { Default().Warn(msg, args...) } + +// Error logs at LevelError on the default Logger. +func Error(msg string, args ...any) { Default().Error(msg, args...) } diff --git a/stream.go b/stream.go new file mode 100644 index 0000000..5ae3c39 --- /dev/null +++ b/stream.go @@ -0,0 +1,65 @@ +package splinter + +import ( + "context" + "log/slog" + "time" +) + +// Level mirrors slog.Level so callers don't need to import slog directly. +type Level = slog.Level + +const ( + LevelDebug Level = slog.LevelDebug + LevelInfo Level = slog.LevelInfo + LevelWarn Level = slog.LevelWarn + LevelError Level = slog.LevelError +) + +// LevelFromString parses a level name (case-insensitive). Accepted: +// "debug", "info", "warn"/"warning", "error". Unknown input returns LevelInfo. +func LevelFromString(s string) Level { + var l slog.Level + if err := l.UnmarshalText([]byte(s)); err != nil { + return LevelInfo + } + return l +} + +// Record is a self-contained log entry passed to every Stream. +type Record struct { + Time time.Time + Level Level + Message string + Attrs map[string]any +} + +// LevelLabel returns a human-friendly label for the record's level. +func (r Record) LevelLabel() string { + switch { + case r.Level < LevelInfo: + return "DEBUG" + case r.Level < LevelWarn: + return "INFO" + case r.Level < LevelError: + return "WARN" + default: + return "ERROR" + } +} + +// Stream is the abstraction for a log output destination. +type Stream interface { + // Name returns a short identifier used in error reporting (e.g. "console"). + Name() string + + // Write handles a single record. Implementations must be safe for + // concurrent use. + Write(ctx context.Context, rec Record) error + + // Enabled reports whether the stream cares about the given level. + Enabled(level Level) bool + + // Close flushes and releases any resources. Called once by Logger.Close. + Close() error +}