From 77c12ade4b8e9146949cca6644f93d03cfbfcabd Mon Sep 17 00:00:00 2001 From: Grigory Zubankov Date: Fri, 20 Mar 2026 09:43:06 +0200 Subject: [PATCH] docs: rewrite README and all godoc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: - Add colored Text encoder to Quick Start and features - Fix Router example (use Text() for console) - Fix progressive enhancement examples (self-contained) - Add › symbol to title godoc (19 files): - Rewrite all exported type/function/method comments - Replace "allows to" with active voice throughout - Add helpful context to key types (Logger, Handler, Entry, Encoder, Bag, SlabWriter, WriterSlot, Router) - Keep field constructors brief but informative - Fix misplaced ContextHandler.Handle comment --- README.md | 405 +++++++++++++++++++++------------------------- bag.go | 73 +++++---- buffer.go | 37 +++-- caller.go | 16 +- contexthandler.go | 40 +++-- encoder.go | 87 +++++----- error.go | 22 +-- field.go | 105 ++++++------ handler.go | 50 +++--- jsonencoder.go | 51 ++++-- level.go | 66 +++++--- logger.go | 99 +++++++----- router.go | 36 +++-- setup.go | 35 ++-- slabwriter.go | 76 +++++---- slog.go | 15 +- textencoder.go | 41 +++-- time.go | 31 ++-- writer.go | 20 +-- writerslot.go | 50 +++--- 20 files changed, 747 insertions(+), 608 deletions(-) diff --git a/README.md b/README.md index 27becfc..a7fc36f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# logf +# › logf [![Go Reference](https://pkg.go.dev/badge/github.com/ssgreg/logf/v2.svg)](https://pkg.go.dev/github.com/ssgreg/logf/v2) [![Build Status](https://github.com/ssgreg/logf/actions/workflows/go.yml/badge.svg)](https://github.com/ssgreg/logf/actions/workflows/go.yml) @@ -7,48 +7,68 @@ Structured logging for Go — context-aware, slog-native, fast. -## Features +## So you want to log things -- **Context-aware fields** — `logf.With(ctx, fields...)` attaches fields to context, automatically included in every log entry -- **Native slog bridge** — `logger.Slog()` returns a `*slog.Logger` sharing the same pipeline, fields, and name. Passes `testing/slogtest` -- **Router** — multi-destination fan-out with per-output level filtering and encoder groups -- **Async buffered I/O** — SlabWriter with pre-allocated slab pool, zero per-message allocations -- **WriterSlot** — placeholder writer for lazy destination initialization -- **JSON and Text encoders** — `logf.JSON()` for production, `logf.Text()` for development (colored, human-readable) -- **Builder API** — `logf.NewLogger().Level(logf.LevelInfo).Build()` for quick setup -- **Zero-alloc hot path** — 0 allocs/op across all benchmarks +You already have `slog`. It works. It's in the standard library. Why would +you need anything else? -## Installation +Well, most of the time you don't. But then one day your service starts +handling 50K requests per second and you notice something funny: your +p99 latency spikes every time the log collector hiccups. Or you realize +that passing a logger through seventeen function arguments just to get +a `request_id` in your database layer is... not great. + +That's where logf comes in. Think of it as slog's cool older sibling who +went to systems programming school and came back with opinions about +memory allocation. + +## What's in the box + +- **Context-aware fields** — attach fields to `context.Context`, they show up in every log entry magically. No more threading loggers through your entire call stack like some kind of dependency injection nightmare. +- **Native slog bridge** — `logger.Slog()` gives you a real `*slog.Logger` that shares everything. Fields, name, pipeline. It's not a wrapper, it's the same logger wearing a different hat. +- **Router** — send logs to multiple destinations. JSON to file, colored text to console, errors to alerting. Each destination gets its own encoder and level filter. A stalled Kibana doesn't block your stderr. +- **SlabWriter** — async buffered I/O that copies your log into a pre-allocated slab in ~17 ns and moves on. A background goroutine handles the actual writing. Your HTTP handler never waits for disk. +- **WriterSlot** — don't know where you're logging to yet? No problem. Start logging, connect the destination later. Early logs are buffered. +- **JSON and Text encoders** — `logf.JSON()` for machines, `logf.Text()` for humans. The text encoder has colors, italics, and a `›` separator that makes your terminal look like it went to design school. +- **Builder API** — one line to start, chain methods to customize. No config structs with 47 fields. +- **Zero-alloc hot path** — the only allocation is Go's variadic `[]Field` slice. Everything else is pooled, pre-allocated, or stack-allocated. + +## Getting started ```bash go get github.com/ssgreg/logf/v2 ``` -## Quick Start +Two lines to logging: ```go -// Minimal — JSON to stderr, debug level, caller enabled: logger := logf.NewLogger().Build() +logger.Info(ctx, "hello, world", logf.String("from", "logf")) +// → {"level":"info","ts":"2026-03-19T14:04:02Z","caller":"main.go:10","msg":"hello, world","from":"logf"} +``` -// Development — colored text output: +Want colors? Say no more: + +```go logger := logf.NewLogger().EncoderFrom(logf.Text()).Build() -// Mar 19 14:04:02.167 [INF] request handled › method=GET status=200 +// Mar 19 14:04:02.167 [INF] hello, world › from=logf → main.go:10 +``` + +Going to production? Crank it up: -// Production — custom JSON encoder, stdout, context fields: +```go logger := logf.NewLogger(). Level(logf.LevelInfo). Output(os.Stdout). - EncoderFrom(logf.JSON().TimeKey("time")). - Context(). Build() ``` -## Logging +## Logging (the fun part) ```go ctx := context.Background() -// Basic levels: +// The classics: logger.Debug(ctx, "starting up") // → {"level":"debug","msg":"starting up"} @@ -60,36 +80,51 @@ logger.Warn(ctx, "slow query", logf.Duration("elapsed", 2*time.Second)) logger.Error(ctx, "connection failed", logf.Error(err)) // → {"level":"error","msg":"connection failed","error":"dial tcp: timeout"} +``` -// Accumulated fields (carried on every subsequent log): +**Accumulated fields** — set once, included forever: + +```go reqLogger := logger.With(logf.String("request_id", "abc-123")) reqLogger.Info(ctx, "processing") // → {"level":"info","msg":"processing","request_id":"abc-123"} -// Groups (nested JSON objects): +reqLogger.Info(ctx, "done", logf.Int("items", 3)) +// → {"level":"info","msg":"done","request_id":"abc-123","items":3} +``` + +**Groups** — nest fields under a key: + +```go logger.Info(ctx, "done", logf.Group("http", logf.String("method", "GET"), logf.Int("status", 200), )) // → {"msg":"done","http":{"method":"GET","status":200}} -// WithGroup — all subsequent fields nested under a key: +// Or permanently with WithGroup: httpLogger := logger.WithGroup("http") httpLogger.Info(ctx, "req", logf.String("method", "GET"), logf.Int("status", 200)) // → {"msg":"req","http":{"method":"GET","status":200}} +``` + +**Named loggers** — know who's talking: -// Named logger: +```go dbLogger := logger.WithName("db") -dbLogger.Info(ctx, "query") // → {"logger":"db","msg":"query"} +dbLogger.Info(ctx, "connected") +// → {"logger":"db","msg":"connected"} ``` -## Context-aware Fields +## Context-aware fields -Attach fields to `context.Context` — they appear in every log entry -automatically, without passing a derived logger through the call stack. +Here's the thing about logging in real applications: you want `request_id` +in every single log entry. With most loggers, that means passing a derived +logger through every function. With logf, you put fields in the context +and forget about them: ```go -// Middleware adds request fields to context: +// In your middleware — add fields once: func middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := logf.With(r.Context(), @@ -101,26 +136,25 @@ func middleware(next http.Handler) http.Handler { }) } -// Deep in the call stack — fields are included automatically: +// Somewhere deep in the call stack — fields are just there: func handleOrder(ctx context.Context, orderID string) { logger.Info(ctx, "processing order", logf.String("order_id", orderID)) // → {"msg":"processing order","request_id":"abc","method":"POST","path":"/orders","order_id":"123"} } ``` -Enable with `.Context()` in the builder, or wrap with `NewContextHandler`: +Enable it with `.Context()` in the builder: ```go -// Builder: logger := logf.NewLogger().Context().Build() +``` -// Manual (for Router pipelines): -handler := logf.NewContextHandler(router) +Want to automatically extract trace IDs from OpenTelemetry spans? +Write a `FieldSource` and pass it to `.Context()`: -// With external field sources (e.g., OTel trace IDs). -// The FieldSource function is called on every log entry — trace_id -// appears automatically in all logs without any manual field passing: -handler := logf.NewContextHandler(router, func(ctx context.Context) []logf.Field { +```go +// Define once: +func otelTraceSource(ctx context.Context) []logf.Field { span := trace.SpanFromContext(ctx) if !span.SpanContext().IsValid() { return nil @@ -128,176 +162,128 @@ handler := logf.NewContextHandler(router, func(ctx context.Context) []logf.Field return []logf.Field{ logf.String("trace_id", span.SpanContext().TraceID().String()), } -}) -// Now every logger.Info(ctx, "...") includes trace_id if the context -// carries an active span — zero changes to application logging code. +} + +// Plug it in: +logger := logf.NewLogger().Context(otelTraceSource).Build() ``` -## logfc — Logger in Context +That's it. From now on, whenever a context carries an active OTel span, +`trace_id` shows up in every log entry. You didn't change a single +logging call in your application. + +## logfc — when you don't want to pass the logger at all -The `logfc` package stores the logger in `context.Context` and provides -top-level logging functions. No need to pass `*logf.Logger` through -function arguments: +The `logfc` package puts the logger in the context. Not a global +singleton — a real logger that picks up new fields as the request +travels deeper through your code. Each layer adds its own details, +and by the time you log something ten functions down, the entry +carries the full story of how it got there: ```go import "github.com/ssgreg/logf/v2/logfc" -// Store logger in context (typically in main or middleware): +// In main or middleware: ctx = logfc.New(ctx, logger) -// Log from anywhere — just pass ctx: +// Anywhere else — no logger argument needed: logfc.Info(ctx, "order processed", logf.Int("items", 3)) -// → {"level":"info",..,"msg":"order processed","items":3} -// Derive logger in context (adds fields for all downstream logs): +// Add fields for everything downstream: ctx = logfc.With(ctx, logf.String("order_id", "ord-789")) logfc.Info(ctx, "payment complete") -// → {"level":"info",..,"msg":"payment complete","order_id":"ord-789"} +// → includes order_id automatically -// Get the underlying logger when needed: +// Need slog? Pull it out: slogger := logfc.Get(ctx).Slog() ``` -If no logger is in context, `logfc` uses `DisabledLogger` — all calls -are no-ops with zero overhead. +If no logger is in context, everything is a no-op. Zero overhead. No panics. -## slog Integration +## slog integration (they're best friends) -`logger.Slog()` produces a fully integrated `*slog.Logger` — not a separate -logger, but a view of the same pipeline. It inherits accumulated `.With()` -fields, `.WithName()` identity, and the full Handler chain. +`logger.Slog()` doesn't create a new logger. It returns a `*slog.Logger` +that IS your logf logger, just with slog's API. Same fields, same name, +same pipeline, same destination. Log with either one — the output is +identical. ```go -// logf logger with context support: -logger := logf.NewLogger().Level(logf.LevelInfo).Context().Build() - -// Derive slog logger — same pipeline, same fields: -slogger := logger.Slog() - -// Pre-accumulated fields carry over: -dbLogger := logger.WithName("db").With(logf.String("component", "postgres")) -dbSlog := dbLogger.Slog() -dbSlog.Info("connected") -// → {"logger":"db","msg":"connected","component":"postgres"} +// These two produce the same output: +logger.Info(ctx, "hello", logf.Int("n", 42)) +logger.Slog().InfoContext(ctx, "hello", "n", 42) ``` -### Third-party libraries - -Many libraries accept `*slog.Logger`. Pass `logger.Slog()` — they -get the same encoder, destination, and async I/O as your application code: +**Give it to your dependencies:** ```go -// Give libraries a slog logger derived from your logf pipeline: db := sqlx.NewClient(sqlx.WithLogger(logger.Slog())) cache := redis.NewClient(redis.WithLogger(logger.Slog())) - -// Their logs go through your Router, SlabWriter, and encoder. -// No separate log configuration per dependency. +// Their logs go through YOUR pipeline. One config to rule them all. ``` -### logf solves slog's context problem - -`slog.InfoContext(ctx, ...)` accepts a context, but the built-in -`slog.JSONHandler` and `slog.TextHandler` ignore it — the context -is passed through but never used. logf's `ContextHandler` actually -reads fields from it. This means `slog.InfoContext(ctx, "msg")` -produces richer output through logf than through standard slog: +**Here's the neat part** — slog has `InfoContext(ctx, ...)` but the +built-in handlers completely ignore the context. logf actually reads +fields from it: ```go -// Standard slog — context is ignored: +// Standard slog — context is decoration: slog.InfoContext(ctx, "order placed") // → {"msg":"order placed"} -// slog through logf — context fields (see Context-aware Fields above) included: +// slog through logf — context fields included: slog.InfoContext(ctx, "order placed") // → {"msg":"order placed","request_id":"abc-123","trace_id":"def-456"} ``` -No special slog middleware, no slog-context packages. Just -`ContextHandler` in the pipeline. - -### Progressive enhancement - -Start with pure slog and add logf features incrementally — -each step is independent, no big-bang migration: +**Progressive enhancement** — start with slog, add logf features one at a time: ```go -// Step 1: faster slog backend -slog.SetDefault(slog.New(logf.NewSlogHandler( - logf.NewSyncHandler(logf.LevelInfo, os.Stderr, logf.JSON().Build()), -))) +// Step 1: just a faster backend — JSON to stderr +sync := logf.NewSyncHandler(logf.LevelInfo, os.Stderr, logf.JSON().Build()) +slog.SetDefault(slog.New(logf.NewSlogHandler(sync))) -// Step 2: add context fields (existing slog calls gain request_id etc.) +// Step 2: add context fields — existing slog calls magically gain request_id slog.SetDefault(slog.New(logf.NewSlogHandler( - logf.NewContextHandler(syncHandler), + logf.NewContextHandler(sync), ))) -// Step 3: add async I/O for throughput -slog.SetDefault(slog.New(logf.NewSlogHandler( - logf.NewContextHandler(router), // router → SlabWriter → file -))) +// Step 3: add async I/O — swap stderr for SlabWriter → file +sw := logf.NewSlabWriter(file, 64*1024, 8) +router, close, _ := logf.NewRouter().Route(logf.JSON().Build(), logf.Output(logf.LevelInfo, sw)).Build() +slog.SetDefault(slog.New(logf.NewSlogHandler(logf.NewContextHandler(router)))) // Step 4 (optional): switch hot paths to logf for typed fields -logger := logf.New(handler) +logger := logf.New(logf.NewContextHandler(router)) logger.Info(ctx, "fast path", logf.Int("status", 200)) ``` -### Mixed codebase +## Router (the traffic cop) -logf and slog can coexist in the same application. Both produce -consistent output through the same pipeline: - -```go -logger := logf.NewLogger().Level(logf.LevelInfo).Context().Build() - -// logf in your code: -logger.Info(ctx, "handled", logf.Int("status", 200)) - -// slog in a dependency: -slogger := logger.Slog() -slogger.InfoContext(ctx, "query executed", "rows", 42) - -// Both outputs include the same context fields (request_id, trace_id) -// and go through the same encoder, router, and destination. -``` - -## Router (multi-destination) - -Route log entries to multiple destinations with independent encoders, -level filters, and I/O strategies. - -```go -enc := logf.JSON().Build() - -// Same encoder, different levels per output: -router, close, _ := logf.NewRouter(). - Route(enc, - logf.Output(logf.LevelDebug, fileWriter), // all levels to file - logf.Output(logf.LevelError, alertWriter), // errors only to alerting - ). - Build() -defer close() -``` - -**Multiple encoder groups** — one Encode call per group, shared across -outputs in that group. Different groups can use different formats: +One log entry, multiple destinations, each with its own rules: ```go +fileSlab := logf.NewSlabWriter(file, 64*1024, 8) jsonEnc := logf.JSON().Build() textEnc := logf.Text().Build() router, close, _ := logf.NewRouter(). Route(jsonEnc, - logf.Output(logf.LevelDebug, fileSlab), // JSON to file (async) + logf.OutputCloser(logf.LevelDebug, fileSlab), // everything to file (async) + logf.Output(logf.LevelError, alertWriter), // errors to alerting ). Route(textEnc, - logf.Output(logf.LevelInfo, os.Stderr), // colored text to console (sync) + logf.Output(logf.LevelInfo, os.Stderr), // colored text to console (sync) ). Build() +defer close() // flushes and closes fileSlab ``` -**Mixed sync/async** — direct write for console, SlabWriter for file. -A stalled file does not block stderr output: +The Router encodes once per encoder group. Two outputs sharing the same +encoder? One encode call. Stalled network destination? The file output +doesn't care — each writer is independent. + +**Mix sync and async** — because console output should be instant but +file writes can be batched: ```go fileSlab := logf.NewSlabWriter(file, 64*1024, 8, @@ -306,107 +292,88 @@ fileSlab := logf.NewSlabWriter(file, 64*1024, 8, router, close, _ := logf.NewRouter(). Route(enc, - logf.Output(logf.LevelDebug, fileSlab), // async to file - logf.Output(logf.LevelInfo, os.Stderr), // sync to console - ). - Build() -defer close() -defer fileSlab.Close() - -// Or transfer SlabWriter ownership to Router with OutputCloser: -router, close, _ := logf.NewRouter(). - Route(enc, - logf.OutputCloser(logf.LevelDebug, fileSlab), // Router.close() closes fileSlab - logf.Output(logf.LevelInfo, os.Stderr), + logf.OutputCloser(logf.LevelDebug, fileSlab), // async, Router closes it + logf.Output(logf.LevelInfo, os.Stderr), // sync, direct write ). Build() defer close() // flushes and closes fileSlab automatically ``` -## SlabWriter (async buffered I/O) +## SlabWriter (the speed demon) -Decouples the caller from I/O with pre-allocated slab buffers: +Here's how it works: your goroutine copies log bytes into a pre-allocated +slab buffer under a mutex (~17 ns memcpy). A background goroutine writes +filled slabs to the destination. Your goroutine never touches the disk. +Never blocks on the network. Just copies bytes and moves on. ```go sw := logf.NewSlabWriter(file, 64*1024, 8, logf.WithFlushInterval(100*time.Millisecond), ) defer sw.Close() - -router, close, _ := logf.NewRouter(). - Route(enc, logf.Output(logf.LevelDebug, sw)). - Build() ``` -- Caller pays ~17 ns (mutex + memcpy), never blocks on I/O -- Slab pool absorbs I/O spikes without dropping messages -- Message integrity: each message is fully delivered or fully dropped, never torn +When the I/O goroutine can't keep up? The slab pool absorbs the spike. +8 slabs × 64 KB = 512 KB of burst tolerance. At 10K msg/sec with +256-byte messages, that's ~200 ms of I/O stall with zero caller impact. -**Drop mode** — for destinations where dropping is better than blocking -(e.g., metrics pipeline, non-critical remote collector): +**Drop mode** — for when losing a log is better than blocking a request: ```go sw := logf.NewSlabWriter(conn, 64*1024, 8, logf.WithDropOnFull(), logf.WithFlushInterval(100*time.Millisecond), - logf.WithErrorWriter(os.Stderr), // log I/O errors to stderr + logf.WithErrorWriter(os.Stderr), ) ``` -When the I/O goroutine can't keep up and all slabs are in flight, -`Write` discards the current slab instead of blocking. The caller -is never delayed — the slab is reused immediately. - -**Monitoring** — `Stats()` provides a snapshot for metrics scrapers: +**Keep an eye on it:** ```go stats := sw.Stats() +// stats.Dropped — messages lost (dropOnFull mode) +// stats.Written — messages accepted // stats.QueuedSlabs — slabs waiting for I/O -// stats.FreeSlabs — slabs available in pool -// stats.Dropped — total messages dropped (dropOnFull mode) -// stats.Written — total messages accepted -// stats.WriteErrors — total I/O errors - -// Example: expose as Prometheus metrics -droppedGauge.Set(float64(stats.Dropped)) -queuedGauge.Set(float64(stats.QueuedSlabs)) +// stats.WriteErrors — I/O failures ``` -See [docs/BUFFERING.md](docs/BUFFERING.md) for capacity planning and benchmarks. +See [docs/BUFFERING.md](docs/BUFFERING.md) for capacity planning. + +## WriterSlot (the patient one) -## WriterSlot (lazy destination) +Sometimes you need a logger before you know where the logs are going. +Config isn't parsed yet. The database connection isn't up. The cloud +SDK hasn't initialized. -Connect a destination after logger creation — useful when the output -is not available at startup: +WriterSlot lets you start logging immediately and connect the real +destination later: ```go slot := logf.NewWriterSlot(logf.WithSlotBuffer(4096)) logger := logf.NewLogger().Output(slot).Build() -// Logger works immediately — writes are buffered. -logger.Info(ctx, "starting up") +logger.Info(ctx, "booting up") // buffered +logger.Info(ctx, "config loaded") // buffered -// Later, when destination is ready: -slot.Set(file) -// Buffered data is flushed, subsequent writes go directly to file. +slot.Set(file) // buffer flushed, future writes go to file + +logger.Info(ctx, "ready to serve") // written directly ``` -## Why logf +## Why not just use slog? -slog from the standard library is sufficient for most applications. logf -targets scenarios where slog falls short: +Honestly? For most apps, slog is fine. logf is for when: -- **High-throughput logging** — encoding is parallel across goroutines, - writes are memcpy into pre-allocated slabs (~17 ns). ~2× faster than - slog under parallel file I/O. -- **Unstable I/O** — slab pool decouples callers from I/O. Under - simulated slow disk, logf p99 = 71µs vs slog p99 = 2.5ms. -- **Request-scoped fields via context** — slog passes context through - but built-in handlers ignore it. logf actually reads fields from it. -- **Decoupled encoding and I/O** — Router encodes once and fans out to - multiple writers independently. +- You're logging **a lot** (>100K entries/sec) and encoding is parallel + across goroutines with pre-allocated slabs (~17 ns per write) +- Your I/O is **unreliable** (slab pool gives you p99 = 71µs vs + slog's p99 = 2.5ms under simulated slow disk) +- You want **context fields** without the ceremony (slog passes context + through but never reads it) +- You need **fan-out** to multiple destinations with independent + encoding and I/O strategies -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for design details. +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the gory details. ## Who uses logf @@ -415,16 +382,16 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for design details. ## Testing ```go -// Discard all logs (silent tests): +// Silent tests (discard everything): logger := logf.DisabledLogger() -// Capture logs to a buffer for assertions: +// Capture logs for assertions: var buf bytes.Buffer logger := logf.NewLogger().Output(&buf).Build() logger.Info(ctx, "hello") -// buf.String() contains JSON output +// buf.String() has your JSON -// Send logs to testing.T (visible with -v or on failure): +// Logs in test output (visible with -v or on failure): type testWriter struct{ t testing.TB } func (w testWriter) Write(p []byte) (int, error) { w.t.Helper() @@ -434,9 +401,9 @@ func (w testWriter) Write(p []byte) (int, error) { logger := logf.NewLogger().Output(testWriter{t}).Build() ``` -## Log Rotation +## Log rotation -logf does not handle log rotation — use `lumberjack` or OS-level `logrotate`: +logf doesn't rotate logs — that's what `lumberjack` and `logrotate` are for: ```go import "gopkg.in/natefinch/lumberjack.v2" @@ -450,19 +417,15 @@ rotator := &lumberjack.Logger{ sw := logf.NewSlabWriter(rotator, 64*1024, 8) ``` -## Caveats - -- **One allocation per log call with fields.** `logger.Info(ctx, "msg", field1, field2)` - allocates a `[]Field` slice on the heap (Go variadic argument semantics). - This is the single `1 allocs/op` visible in benchmarks. Calls without - fields (`logger.Info(ctx, "msg")`) are zero-alloc. +## The fine print -- **Oversized messages allocate (SlabWriter only).** When using SlabWriter, - messages larger than `slabSize` require `make([]byte, len(p))` + copy. - Typical log entries (100–500 bytes) with typical slab sizes (16–64 KB) - never hit this path. Sync handlers are not affected. +- **One allocation per log call with fields.** That's Go's variadic + `[]Field` slice. Calls without fields are zero-alloc. +- **Oversized messages allocate (SlabWriter only).** Messages bigger than + `slabSize` get a dedicated buffer. Normal log entries (100–500 bytes) + with normal slabs (16–64 KB) never hit this. -## Documentation +## Learn more - [Architecture & design philosophy](docs/ARCHITECTURE.md) - [Buffering strategies & capacity planning](docs/BUFFERING.md) diff --git a/bag.go b/bag.go index 05ca1f4..5f0d89b 100644 --- a/bag.go +++ b/bag.go @@ -21,12 +21,13 @@ type bagCache struct { // Slots are 1-based: 0 means "uninitialized / no caching". var nextSlot atomic.Int32 -// AllocEncoderSlot returns a unique slot index for an encoder to use -// with Bag.LoadCache / Bag.StoreCache. Slots are 1-based; zero value -// means "no slot" and cache methods become no-ops. +// AllocEncoderSlot returns a unique 1-based slot index for an encoder to +// use with Bag.LoadCache and Bag.StoreCache. Call this once when you +// create an encoder — the slot lets the Bag cache encoded bytes per +// encoder format so repeated encoding is nearly free. // -// Call once per encoder at creation time. If all slots are taken, -// returns 0 (no caching, graceful degradation). +// If all slots are taken, returns 0 (no caching, graceful degradation — +// everything still works, just without the cache speedup). func AllocEncoderSlot() int { s := int(nextSlot.Add(1)) if s > maxCacheSlots { @@ -35,9 +36,12 @@ func AllocEncoderSlot() int { return s } -// Bag is an immutable linked list of Fields. -// Each With creates a new node pointing to the parent — O(1), no copies. -// Bag is safe to share across goroutines. +// Bag is an immutable, goroutine-safe linked list of Fields — the backbone +// of logf's zero-copy field accumulation. Every call to With or WithGroup +// creates a new node pointing to the parent in O(1) time with no field +// copies. The encoder walks the chain at encoding time, and results are +// cached per encoder so repeated encoding of the same Bag is essentially +// free. type Bag struct { fields []Field parent *Bag @@ -45,26 +49,30 @@ type Bag struct { cache atomic.Pointer[bagCache] } -// NewBag creates a new Bag with the given fields. +// NewBag creates a root Bag node with the given fields. Most of the time +// you will not call this directly — Logger.With and logf.With handle Bag +// creation for you. func NewBag(fs ...Field) *Bag { return &Bag{fields: fs} } -// With returns a new Bag that contains both the existing fields and the -// given additional fields. The original Bag is not modified. -// O(1): no field copy, new node points to parent. +// With returns a new Bag that includes the given additional fields. The +// original Bag is not modified — the new node simply points to the parent. +// O(1) time, zero copies. func (b *Bag) With(fs ...Field) *Bag { return &Bag{fields: fs, parent: b} } -// WithGroup returns a new Bag that opens a named group. -// All fields added to descendant nodes (via With) will be logically -// nested under this group when encoded. The original Bag is not modified. +// WithGroup returns a new Bag that opens a named group. All fields added +// to descendant nodes via subsequent With calls will be logically nested +// under this group name when encoded (e.g., as a nested JSON object). +// The original Bag is not modified. func (b *Bag) WithGroup(name string) *Bag { return &Bag{group: name, parent: b} } -// Group returns the group name for this Bag node, or empty string. +// Group returns the group name for this Bag node, or an empty string if +// it is a regular field node. func (b *Bag) Group() string { if b == nil { return "" @@ -72,9 +80,9 @@ func (b *Bag) Group() string { return b.group } -// Fields returns all fields in the Bag chain, parent-first order. -// This allocates a new slice when the chain has more than one node. -// For cache-aware encoding, use OwnFields + Parent instead. +// Fields collects all fields across the entire Bag chain in parent-first +// order. This allocates a new slice when the chain has more than one node, +// so for hot-path encoding prefer walking OwnFields + Parent directly. func (b *Bag) Fields() []Field { if b == nil { return nil @@ -100,8 +108,8 @@ func (b *Bag) Fields() []Field { return all } -// OwnFields returns only the fields stored in this Bag node, -// not including parent fields. +// OwnFields returns only the fields stored directly in this Bag node, +// without walking up to parents. Useful for cache-aware encoding. func (b *Bag) OwnFields() []Field { if b == nil { return nil @@ -109,7 +117,8 @@ func (b *Bag) OwnFields() []Field { return b.fields } -// Parent returns the parent Bag, or nil for a root node. +// Parent returns the parent Bag in the linked list, or nil if this is +// the root node. func (b *Bag) Parent() *Bag { if b == nil { return nil @@ -117,8 +126,8 @@ func (b *Bag) Parent() *Bag { return b.parent } -// LoadCache returns cached encoded bytes for the given encoder slot, -// or nil on cache miss. Slot 0 (uninitialized) always returns nil. +// LoadCache returns previously cached encoded bytes for the given encoder +// slot, or nil on a cache miss. Slot 0 (no caching) always returns nil. func (b *Bag) LoadCache(slot int) []byte { if slot == 0 || b == nil { return nil @@ -130,9 +139,9 @@ func (b *Bag) LoadCache(slot int) []byte { return c.slots[slot-1] } -// StoreCache stores encoded bytes in the cache for the given encoder -// slot. Slot 0 (uninitialized) is a no-op. The bagCache is allocated -// lazily on first store. +// StoreCache saves encoded bytes for the given encoder slot so future +// Encode calls can skip re-encoding this Bag. Slot 0 (no caching) is a +// no-op. The internal cache structure is allocated lazily on first store. func (b *Bag) StoreCache(slot int, data []byte) { if slot == 0 || b == nil { return @@ -147,7 +156,8 @@ func (b *Bag) StoreCache(slot int, data []byte) { c.slots[slot-1] = data } -// HasField reports whether the Bag chain contains a field with the given key. +// HasField reports whether any node in the Bag chain contains a field with +// the given key. Walks the full chain from this node up to the root. func (b *Bag) HasField(key string) bool { for node := b; node != nil; node = node.parent { for i := range node.fields { @@ -162,12 +172,15 @@ func (b *Bag) HasField(key string) bool { type bagKey struct{} -// ContextWithBag returns a new context with the given Bag associated. +// ContextWithBag returns a new context carrying the given Bag. This is +// the low-level API — most callers should use logf.With(ctx, fields...) +// instead, which handles Bag creation and chaining automatically. func ContextWithBag(ctx context.Context, bag *Bag) context.Context { return context.WithValue(ctx, bagKey{}, bag) } -// BagFromContext returns the Bag associated with the context, or nil. +// BagFromContext returns the Bag stored in the context, or nil if none +// was set. Safe to call on any context. func BagFromContext(ctx context.Context) *Bag { bag, _ := ctx.Value(bagKey{}).(*Bag) diff --git a/buffer.go b/buffer.go index a349f26..8b95795 100644 --- a/buffer.go +++ b/buffer.go @@ -14,32 +14,35 @@ var _bufferPool = sync.Pool{New: func() any { return NewBufferWithCapacity(PageSize) }} -// GetBuffer returns a *Buffer from the pool. The caller must call -// Buffer.Free when done to return it to the pool. +// GetBuffer grabs a *Buffer from the pool, reset and ready to use. When +// you are done, call Buffer.Free to return it — this keeps allocations +// close to zero on the hot path. func GetBuffer() *Buffer { buf := _bufferPool.Get().(*Buffer) buf.Reset() return buf } -// Free returns the Buffer to the pool. The Buffer must not be used after -// calling Free. +// Free returns the Buffer to the pool for reuse. The Buffer must not be +// accessed after calling Free. func (b *Buffer) Free() { _bufferPool.Put(b) } -// NewBuffer creates the new instance of Buffer with default capacity. +// NewBuffer creates a new Buffer with the default 4 KB capacity. func NewBuffer() *Buffer { return NewBufferWithCapacity(PageSize) } -// NewBufferWithCapacity creates the new instance of Buffer with the given -// capacity. +// NewBufferWithCapacity creates a new Buffer pre-allocated to the given +// number of bytes. func NewBufferWithCapacity(capacity int) *Buffer { return &Buffer{make([]byte, 0, capacity)} } -// Buffer is a helping wrapper for byte slice. +// Buffer is a lightweight byte buffer used throughout the encoder pipeline. +// It wraps a []byte with append-oriented methods and integrates with +// sync.Pool via GetBuffer/Free for allocation-free encoding. type Buffer struct { Data []byte } @@ -56,8 +59,8 @@ func (b *Buffer) String() string { return string(b.Bytes()) } -// EnsureSize ensures that the Buffer is able to append 's' bytes without -// a further realloc. +// EnsureSize guarantees that at least s bytes can be appended without a +// reallocation. func (b *Buffer) EnsureSize(s int) { if cap(b.Data)-len(b.Data) < s { tmpLen := len(b.Data) @@ -67,8 +70,8 @@ func (b *Buffer) EnsureSize(s int) { } } -// ExtendBytes extends the Buffer with the given size and returns a slice -// to the extended part of the Buffer. +// ExtendBytes grows the Buffer by s bytes and returns a slice pointing to +// the newly added region. Useful for in-place encoding (e.g., base64). func (b *Buffer) ExtendBytes(s int) []byte { b.EnsureSize(s) n := len(b.Data) @@ -102,8 +105,8 @@ func (b *Buffer) Reset() { b.Data = b.Data[:0] } -// Back returns the last byte of the underlying byte slice. A caller is in -// charge of checking that the Buffer is not empty. +// Back returns the last byte in the Buffer. The caller must ensure the +// Buffer is not empty. func (b *Buffer) Back() byte { return b.Data[len(b.Data)-1] } @@ -123,13 +126,13 @@ func (b *Buffer) Cap() int { return cap(b.Data) } -// AppendUint appends the string form in the base 10 of the given unsigned -// integer. +// AppendUint appends the base-10 string representation of the given +// unsigned integer. func (b *Buffer) AppendUint(n uint64) { b.Data = strconv.AppendUint(b.Data, n, 10) } -// AppendInt appends the string form in the base 10 of the given integer. +// AppendInt appends the base-10 string representation of the given integer. func (b *Buffer) AppendInt(n int64) { b.Data = strconv.AppendInt(b.Data, n, 10) } diff --git a/caller.go b/caller.go index 1d6b6fc..82589b9 100644 --- a/caller.go +++ b/caller.go @@ -7,8 +7,9 @@ import ( "unsafe" ) -// CallerPC captures the program counter of the caller, skipping the -// given number of frames. Returns 0 if the caller cannot be determined. +// CallerPC captures the program counter of the caller, skipping the given +// number of stack frames. Returns 0 if the caller cannot be determined. +// You usually do not need to call this directly — the Logger handles it. func CallerPC(skip int) uintptr { var pcs [1]uintptr if runtime.Callers(skip+2, pcs[:]) < 1 { @@ -51,10 +52,13 @@ func fileWithPackage(file string) string { return file[found+1:] } -// CallerEncoder is the function type to encode a caller program counter. +// CallerEncoder is a function that resolves a program counter and writes +// the caller location (file + line) into the log output via TypeEncoder. type CallerEncoder func(pc uintptr, m TypeEncoder) -// ShortCallerEncoder resolves the given PC and encodes it as package/file:line. +// ShortCallerEncoder formats the caller as "package/file.go:line" — compact +// enough for log output while still letting you find the source. This is +// the default CallerEncoder. func ShortCallerEncoder(pc uintptr, m TypeEncoder) { file, line := callerFrame(pc) var callerBuf [64]byte @@ -68,7 +72,9 @@ func ShortCallerEncoder(pc uintptr, m TypeEncoder) { runtime.KeepAlive(&b) } -// FullCallerEncoder resolves the given PC and encodes it as full/path/file:line. +// FullCallerEncoder formats the caller as the full filesystem path with +// line number. More verbose than ShortCallerEncoder but unambiguous when +// you have multiple packages with the same file name. func FullCallerEncoder(pc uintptr, m TypeEncoder) { file, line := callerFrame(pc) var callerBuf [256]byte diff --git a/contexthandler.go b/contexthandler.go index 2f32c80..32503b4 100644 --- a/contexthandler.go +++ b/contexthandler.go @@ -2,49 +2,63 @@ package logf import "context" -// With returns a new context with the given fields added to the context's Bag. -// If the context already has a Bag, the fields are appended to it. +// With returns a new context carrying the given fields. If the context +// already has a Bag, the fields are appended to it. This is the primary +// way to attach request-scoped data (trace IDs, user info, etc.) that +// will automatically appear in every log entry — no need to pass fields +// around manually. func With(ctx context.Context, fs ...Field) context.Context { bag := BagFromContext(ctx).With(fs...) return ContextWithBag(ctx, bag) } -// HasField reports whether the context's Bag contains a field with the given key. +// HasField reports whether the context's Bag contains a field with the +// given key. Useful for conditional field injection — for example, +// adding a trace ID only if one is not already present. func HasField(ctx context.Context, key string) bool { return BagFromContext(ctx).HasField(key) } -// Fields returns the fields from the context's Bag, or nil. +// Fields returns all fields from the context's Bag, or nil if the context +// has no Bag. func Fields(ctx context.Context) []Field { return BagFromContext(ctx).Fields() } -// FieldSource extracts fields from a context. It is used by ContextHandler -// to support external field sources (e.g. tracing, request ID middleware). +// FieldSource is a function that extracts fields from a context. Pass one +// to NewContextHandler or LoggerBuilder.Context to automatically inject +// fields from external sources — tracing libraries, request ID middleware, +// authentication context, you name it. type FieldSource func(ctx context.Context) []Field -// ContextHandler is an Handler middleware that extracts the Bag and -// external fields from the context and attaches them to the Entry before -// passing it downstream. +// ContextHandler is the Handler middleware that makes context-based logging +// work. It extracts the Bag from the context (populated by logf.With) and +// any external fields from FieldSource functions, attaches them to the +// Entry, and passes it downstream. Without a ContextHandler in the +// pipeline, context fields are silently ignored. type ContextHandler struct { next Handler sources []FieldSource } // NewContextHandler returns a new ContextHandler wrapping the given Handler. -// Optional FieldSource functions are called on each Handle to collect -// additional fields from the context. These fields are prepended to Entry.Fields. +// Optional FieldSource functions are called on every Handle to pull in +// additional fields from the context (prepended to Entry.Fields so they +// appear before per-call fields). func NewContextHandler(next Handler, sources ...FieldSource) *ContextHandler { return &ContextHandler{next: next, sources: sources} } -// Handle extracts the Bag from ctx, collects fields from external sources, -// and delegates to the next Handler. +// Enabled delegates to the downstream Handler to check whether the given +// level is active. func (w *ContextHandler) Enabled(ctx context.Context, lvl Level) bool { return w.next.Enabled(ctx, lvl) } +// Handle extracts the Bag from the context, collects fields from any +// registered FieldSource functions, attaches everything to the Entry, +// and hands it off to the downstream Handler. func (w *ContextHandler) Handle(ctx context.Context, e Entry) error { if bag := BagFromContext(ctx); bag != nil { e.Bag = bag diff --git a/encoder.go b/encoder.go index 5b7a577..4f1321e 100644 --- a/encoder.go +++ b/encoder.go @@ -5,61 +5,65 @@ import ( "unsafe" ) -// Encoder defines the interface to create your own log format. +// Encoder is the interface that turns an Entry into bytes — it decides +// your log format (JSON, text, or whatever you dream up). The built-in +// JSON and Text encoders handle most needs, but implementing Encoder +// lets you go fully custom. // -// Encode serializes the Entry and returns a *Buffer obtained from the -// pool. The caller must call Buffer.Free when done with the returned -// buffer. Encode is safe for concurrent use — implementations must -// handle internal cloning/pooling as needed. +// Encode serializes the Entry and returns a pooled *Buffer. The caller +// must call Buffer.Free when done. Encode is safe for concurrent use — +// implementations handle internal cloning and buffer pooling. // -// Clone returns an independent copy of the Encoder suitable for use in -// another goroutine. The copy shares immutable configuration but has its -// own mutable state (buffer pointers, counters, etc.). +// Clone returns an independent copy that shares immutable config but +// has its own mutable state, suitable for use in another goroutine. type Encoder interface { Encode(Entry) (*Buffer, error) Clone() Encoder } -// ArrayEncoder defines the interface to create your own array logger. +// ArrayEncoder lets your custom types serialize themselves as JSON arrays +// (or whatever array representation the encoder uses). Implement +// EncodeLogfArray and pass your type to logf.Array(). // // Example: // -// type stringArray []string -// -// func (o stringArray) EncodeLogfArray(e TypeEncoder) error { -// for i := range o { -// e.EncodeTypeString(o[i]) -// } -// -// return nil -// } +// type stringArray []string // +// func (o stringArray) EncodeLogfArray(e TypeEncoder) error { +// for i := range o { +// e.EncodeTypeString(o[i]) +// } +// return nil +// } type ArrayEncoder interface { EncodeLogfArray(TypeEncoder) error } -// ObjectEncoder defines the interface to create your own object logger. +// ObjectEncoder lets your custom types serialize themselves as structured +// objects with named fields. This is how you get zero-allocation logging +// for your domain types — no reflection, no fmt.Sprintf, just direct +// calls to the encoder. // // Example: // -// type user struct { -// Username string -// Password string -// } -// -// func (u user) EncodeLogfObject(e FieldEncoder) error { -// e.EncodeFieldString("username", u.Username) -// e.EncodeFieldString("password", u.Password) -// -// return nil -// } +// type user struct { +// Username string +// Password string +// } // +// func (u user) EncodeLogfObject(e FieldEncoder) error { +// e.EncodeFieldString("username", u.Username) +// e.EncodeFieldString("password", u.Password) +// return nil +// } type ObjectEncoder interface { EncodeLogfObject(FieldEncoder) error } -// TypeEncoder defines the interface that allows to encode basic types. -// Encoder companion. +// TypeEncoder provides methods for encoding individual values (scalars, +// slices, arrays, objects) without field names. It is the companion +// interface used by TimeEncoder, DurationEncoder, LevelEncoder, and +// CallerEncoder to write their output into the buffer. type TypeEncoder interface { EncodeTypeAny(interface{}) EncodeTypeBool(bool) @@ -79,8 +83,10 @@ type TypeEncoder interface { EncodeTypeUnsafeBytes(unsafe.Pointer) } -// FieldEncoder defines the interface that allows to encode basic types with -// field names. Encoder companion. +// FieldEncoder provides methods for encoding key-value pairs. It is the +// interface that ObjectEncoder and ErrorEncoder receive to write named +// fields into the output. Each method encodes one field with the given +// key and typed value. type FieldEncoder interface { EncodeFieldAny(string, interface{}) EncodeFieldBool(string, bool) @@ -101,16 +107,17 @@ type FieldEncoder interface { EncodeFieldGroup(string, []Field) } -// TypeEncoderFactory defines the interface that allows to reuse Encoder -// internal-defined TypeEncoder in other encoder. -// -// E.g. logf json encoder implements TypeEncoderFactory allowing all other -// encoders to use json encoding functionality in some cases. +// TypeEncoderFactory creates a TypeEncoder that writes into the given Buffer. +// This lets one encoder borrow another encoder's formatting — for example, +// the text encoder uses the JSON encoder's TypeEncoderFactory to render +// nested objects and arrays in JSON syntax within otherwise plain-text output. type TypeEncoderFactory interface { TypeEncoder(*Buffer) TypeEncoder } -// EncoderBuilder can build an Encoder. Implemented by JSONEncoderBuilder. +// EncoderBuilder builds an Encoder from accumulated configuration. +// Implemented by JSONEncoderBuilder and TextEncoderBuilder, and accepted +// by LoggerBuilder.EncoderFrom for composable builder chains. type EncoderBuilder interface { Build() Encoder } diff --git a/error.go b/error.go index eb787de..7f1398b 100644 --- a/error.go +++ b/error.go @@ -2,25 +2,27 @@ package logf import "fmt" -// ErrorEncoder is the function type to encode the given error. +// ErrorEncoder is a function that writes an error into the log output. +// It receives the field key, the error, and a FieldEncoder so it can +// emit one or more fields (e.g., a short message plus a verbose stack). type ErrorEncoder func(string, error, FieldEncoder) -// DefaultErrorEncoder encodes the given error as a set of fields. -// -// A mandatory field with the given key and an optional field with the -// full verbose error message. +// DefaultErrorEncoder encodes an error as one or two fields: the error +// message under the given key, and (if the error implements fmt.Formatter) +// a verbose field with the full "%+v" output (stack traces, etc.). func DefaultErrorEncoder(key string, err error, enc FieldEncoder) { NewErrorEncoder.Default()(key, err, enc) } -// ErrorEncoderConfig allows to configure ErrorEncoder. +// ErrorEncoderConfig controls how errors are encoded — specifically the +// verbose field suffix and whether verbose output is included at all. type ErrorEncoderConfig struct { VerboseFieldSuffix string NoVerboseField bool } -// WithDefaults returns the new config in which all uninitialized fields are -// filled with their default values. +// WithDefaults returns a copy of the config with zero-value fields replaced +// by defaults (verbose suffix ".verbose"). func (c ErrorEncoderConfig) WithDefaults() ErrorEncoderConfig { if c.VerboseFieldSuffix == "" { c.VerboseFieldSuffix = ".verbose" @@ -29,8 +31,8 @@ func (c ErrorEncoderConfig) WithDefaults() ErrorEncoderConfig { return c } -// NewErrorEncoder creates the new instance of the ErrorEncoder with the -// given ErrorEncoderConfig. +// NewErrorEncoder creates an ErrorEncoder with the given config. Call it +// as a function: NewErrorEncoder(cfg) returns an ErrorEncoder. var NewErrorEncoder = errorEncoderGetter( func(c ErrorEncoderConfig) ErrorEncoder { return func(key string, err error, enc FieldEncoder) { diff --git a/field.go b/field.go index dead923..b538b2d 100644 --- a/field.go +++ b/field.go @@ -8,7 +8,7 @@ import ( "unsafe" ) -// Bool returns a new Field with the given key and bool. +// Bool returns a Field that carries a boolean value under the given key. func Bool(k string, v bool) Field { var tmp int64 if v { @@ -18,87 +18,88 @@ func Bool(k string, v bool) Field { return Field{Key: k, Type: FieldTypeBool, Val: tmp} } -// Int returns a new Field with the given key and int. +// Int returns a Field that carries an int value under the given key. func Int(k string, v int) Field { return Field{Key: k, Type: FieldTypeInt64, Val: int64(v)} } -// Int64 returns a new Field with the given key and int64. +// Int64 returns a Field that carries an int64 value under the given key. func Int64(k string, v int64) Field { return Field{Key: k, Type: FieldTypeInt64, Val: v} } -// Int32 returns a new Field with the given key and int32. +// Int32 returns a Field that carries an int32 value under the given key. func Int32(k string, v int32) Field { return Field{Key: k, Type: FieldTypeInt64, Val: int64(v)} } -// Int16 returns a new Field with the given key and int16. +// Int16 returns a Field that carries an int16 value under the given key. func Int16(k string, v int16) Field { return Field{Key: k, Type: FieldTypeInt64, Val: int64(v)} } -// Int8 returns a new Field with the given key and int.8 +// Int8 returns a Field that carries an int8 value under the given key. func Int8(k string, v int8) Field { return Field{Key: k, Type: FieldTypeInt64, Val: int64(v)} } -// Uint returns a new Field with the given key and uint. +// Uint returns a Field that carries a uint value under the given key. func Uint(k string, v uint) Field { return Field{Key: k, Type: FieldTypeUint64, Val: int64(v)} } -// Uint64 returns a new Field with the given key and uint64. +// Uint64 returns a Field that carries a uint64 value under the given key. func Uint64(k string, v uint64) Field { return Field{Key: k, Type: FieldTypeUint64, Val: int64(v)} } -// Uint32 returns a new Field with the given key and uint32. +// Uint32 returns a Field that carries a uint32 value under the given key. func Uint32(k string, v uint32) Field { return Field{Key: k, Type: FieldTypeUint64, Val: int64(v)} } -// Uint16 returns a new Field with the given key and uint16. +// Uint16 returns a Field that carries a uint16 value under the given key. func Uint16(k string, v uint16) Field { return Field{Key: k, Type: FieldTypeUint64, Val: int64(v)} } -// Uint8 returns a new Field with the given key and uint8. +// Uint8 returns a Field that carries a uint8 value under the given key. func Uint8(k string, v uint8) Field { return Field{Key: k, Type: FieldTypeUint64, Val: int64(v)} } -// Float64 returns a new Field with the given key and float64. +// Float64 returns a Field that carries a float64 value under the given key. func Float64(k string, v float64) Field { return Field{Key: k, Type: FieldTypeFloat64, Val: int64(math.Float64bits(v))} } -// Float32 returns a new Field with the given key and float32. +// Float32 returns a Field that carries a float32 value under the given key. func Float32(k string, v float32) Field { return Field{Key: k, Type: FieldTypeFloat64, Val: int64(math.Float64bits(float64(v)))} } -// Duration returns a new Field with the given key and time.Duration. +// Duration returns a Field that carries a time.Duration value under the given key. func Duration(k string, v time.Duration) Field { return Field{Key: k, Type: FieldTypeDuration, Val: int64(v)} } -// Bytes returns a new Field with the given key and slice of bytes. +// Bytes returns a Field that carries a []byte value under the given key. +// The bytes are base64-encoded in JSON output. func Bytes(k string, v []byte) Field { return Field{Key: k, Type: FieldTypeBytes, Ptr: unsafe.Pointer(unsafe.SliceData(v)), Val: int64(len(v))} } -// String returns a new Field with the given key and string. +// String returns a Field that carries a string value under the given key. func String(k string, v string) Field { return Field{Key: k, Type: FieldTypeBytesToString, Ptr: unsafe.Pointer(unsafe.StringData(v)), Val: int64(len(v))} } -// Strings returns a new Field with the given key and slice of strings. +// Strings returns a Field that carries a []string value under the given key. func Strings(k string, v []string) Field { return Field{Key: k, Type: FieldTypeBytesToStrings, Ptr: unsafe.Pointer(unsafe.SliceData(v)), Val: int64(len(v))} } -// Ints returns a new Field with the given key and slice of ints. +// Ints returns a Field that carries a []int value under the given key. func Ints(k string, v []int) Field { if unsafe.Sizeof(int(0)) == unsafe.Sizeof(int64(0)) { return Field{Key: k, Type: FieldTypeBytesToInts64, Ptr: unsafe.Pointer(unsafe.SliceData(v)), Val: int64(len(v))} @@ -111,32 +112,33 @@ func Ints(k string, v []int) Field { return Field{Key: k, Type: FieldTypeBytesToInts64, Ptr: unsafe.Pointer(unsafe.SliceData(s)), Val: int64(len(s))} } -// Ints64 returns a new Field with the given key and slice of 64-bit ints. +// Ints64 returns a Field that carries a []int64 value under the given key. func Ints64(k string, v []int64) Field { return Field{Key: k, Type: FieldTypeBytesToInts64, Ptr: unsafe.Pointer(unsafe.SliceData(v)), Val: int64(len(v))} } -// Floats64 returns a new Field with the given key and slice of 64-bit floats. +// Floats64 returns a Field that carries a []float64 value under the given key. func Floats64(k string, v []float64) Field { return Field{Key: k, Type: FieldTypeBytesToFloats64, Ptr: unsafe.Pointer(unsafe.SliceData(v)), Val: int64(len(v))} } -// Durations returns a new Field with the given key and slice of time.Duration. +// Durations returns a Field that carries a []time.Duration value under the given key. func Durations(k string, v []time.Duration) Field { return Field{Key: k, Type: FieldTypeBytesToDurations, Ptr: unsafe.Pointer(unsafe.SliceData(v)), Val: int64(len(v))} } -// NamedError returns a new Field with the given key and error. +// NamedError returns a Field that carries an error value under the given key. func NamedError(k string, v error) Field { return Field{Key: k, Type: FieldTypeError, Any: v} } -// Error returns a new Field with the given error. Key is 'error'. +// Error returns a Field that carries an error under the key "error". +// It is shorthand for NamedError("error", v). func Error(v error) Field { return NamedError("error", v) } -// Time returns a new Field with the given key and time.Time. +// Time returns a Field that carries a time.Time value under the given key. func Time(k string, v time.Time) Field { if v.IsZero() { return Field{Key: k, Type: FieldTypeTime} @@ -144,18 +146,21 @@ func Time(k string, v time.Time) Field { return Field{Key: k, Type: FieldTypeTime, Val: v.UnixNano(), Any: v.Location()} } -// Array returns a new Field with the given key and ArrayEncoder. +// Array returns a Field that carries a custom array value under the given key. +// The ArrayEncoder's EncodeLogfArray method is called at encoding time. func Array(k string, v ArrayEncoder) Field { return Field{Key: k, Type: FieldTypeArray, Any: v} } -// Object returns a new Field with the given key and ObjectEncoder. +// Object returns a Field that carries a custom object value under the given key. +// The ObjectEncoder's EncodeLogfObject method is called at encoding time. func Object(k string, v ObjectEncoder) Field { return Field{Key: k, Type: FieldTypeObject, Any: v} } -// Inline returns a new Field that encodes the given ObjectEncoder's fields -// directly into the parent object, without a wrapping key. +// Inline returns a Field that splices the ObjectEncoder's fields directly +// into the parent object — no wrapping key, no nesting. Perfect for +// flattening a struct's fields into the log entry. // // Example: // @@ -168,8 +173,9 @@ func Inline(v ObjectEncoder) Field { return Object("", v) } -// Group returns a new Field that encodes the given fields as a nested -// object under the given key. +// Group returns a Field that nests the given fields as a sub-object +// under the given key. Think of it as an inline WithGroup for a single +// log call. // // Example: // @@ -181,7 +187,8 @@ func Group(k string, fs ...Field) Field { return Field{Key: k, Type: FieldTypeGroup, Any: fs} } -// Stringer returns a new Field with the given key and Stringer. +// Stringer returns a Field that calls v.String() and logs the result as +// a string under the given key. Nil values are logged as "nil". func Stringer(k string, v fmt.Stringer) Field { if v == nil { return String(k, "nil") @@ -190,29 +197,32 @@ func Stringer(k string, v fmt.Stringer) Field { return String(k, v.String()) } -// Formatter returns a new Field with the given key, verb and interface to -// format. +// Formatter returns a Field that formats the value with fmt.Sprintf using +// the given verb and stores the result as a string. func Formatter(k string, verb string, v interface{}) Field { return String(k, fmt.Sprintf(verb, v)) } -// FormatterV returns a new Field with the given key and interface to format. -// It uses the predefined verb "%#v" (a Go-syntax representation of the value). +// FormatterV returns a Field that formats the value with "%#v" (Go-syntax +// representation) and stores the result as a string under the given key. func FormatterV(k string, v interface{}) Field { return Formatter(k, "%#v", v) } -// ByteString returns a new Field with the given key and []byte that is -// interpreted as a UTF-8 string (not base64-encoded like Bytes). +// ByteString returns a Field that interprets the []byte as a UTF-8 string +// (not base64-encoded like Bytes). Use this when you have text data in a +// byte slice and want it logged as a readable string. func ByteString(k string, v []byte) Field { return String(k, unsafe.String(unsafe.SliceData(v), len(v))) } -// Any returns a new Filed with the given key and value of any type. Is tries -// to choose the best way to represent key-value pair as a Field. +// Any returns a Field for an arbitrary value, picking the most efficient +// typed representation it can via a type switch. It handles all the +// common Go types (scalars, pointers, slices, time, errors, Stringer) +// and falls back to reflection for named types. // -// Note that Any may not choose the most efficient typed method for every type. -// Use specific Field methods for better performance. +// For hot paths, prefer the specific constructors (String, Int, etc.) — +// they avoid the type switch overhead entirely. func Any(k string, v interface{}) Field { switch rv := v.(type) { // Scalars. @@ -352,7 +362,8 @@ func Any(k string, v interface{}) Field { return Field{Key: k, Type: FieldTypeAny, Any: v} } -// FieldType specifies how to handle Field data. +// FieldType tells the encoder how to interpret the data packed inside a Field. +// Each type corresponds to a specific encoding path in the FieldEncoder. type FieldType byte // Set of FileType values. @@ -383,7 +394,10 @@ const ( FieldTypeGroup ) -// Field hold data of a specific field. +// Field is the fundamental key-value unit in logf's structured logging. +// Every Bool(), String(), Int(), etc. call creates one of these. Fields +// are designed to be small (56 bytes) and allocation-free for scalar +// types — the value is packed inline rather than boxed into an interface. // // Layout (56 bytes): // @@ -400,8 +414,9 @@ type Field struct { Val int64 } -// Accept interprets Field data according to FieldType and calls appropriate -// FieldEncoder function. +// Accept dispatches the Field to the appropriate FieldEncoder method based +// on its FieldType. This is the bridge between the type-erased Field +// storage and the strongly-typed encoder interface. func (fd Field) Accept(v FieldEncoder) { switch fd.Type { case FieldTypeAny: diff --git a/handler.go b/handler.go index b743283..33868e7 100644 --- a/handler.go +++ b/handler.go @@ -6,49 +6,61 @@ import ( "time" ) -// Entry holds a single log message and fields. +// Entry is a single log record — the thing that travels through the pipeline +// from Logger to Handler to Encoder. It carries the message, level, timestamp, +// caller info, and all accumulated fields (both from Logger.With and from +// context). You rarely create one yourself; the Logger builds it for you on +// every Debug/Info/Warn/Error call. type Entry struct { - // LoggerBag holds logger-scoped fields (from Logger.With). + // LoggerBag holds logger-scoped fields added via Logger.With. These are + // typically service-level context like "component" or "version". LoggerBag *Bag - // Bag holds request-scoped fields (from context via ContextHandler). + // Bag holds request-scoped fields extracted from context by ContextHandler. + // Think trace IDs, request metadata — anything you stuff into the context + // via logf.With(ctx, ...). Bag *Bag - // Fields specifies data fields of a log message. + // Fields are the per-call fields passed directly to Debug/Info/Warn/Error. Fields []Field - // Level specifies a severity level of a log message. + // Level is the severity of this log record. Level Level - // Time specifies a timestamp of a log message. + // Time is when this log record was created (usually time.Now()). Time time.Time - // LoggerName specifies a non-unique name of a logger. - // Can be empty. + // LoggerName is the dot-separated name set via Logger.WithName. + // Empty string means the logger has no name. LoggerName string - // Text specifies a text message of a log message. + // Text is the human-readable log message. Text string - // CallerPC is the program counter of the caller. - // Zero means caller info is not available. + // CallerPC is the program counter of the call site. Zero means caller + // reporting is disabled or unavailable. CallerPC uintptr } -// Handler is the interface that should do real logging stuff. +// Handler is the core interface that processes log entries. Implement it to +// control where and how logs are written. The built-in handlers — SyncHandler, +// ContextHandler, and Router — cover most use cases, but you can wrap or +// replace them for custom behavior like sampling, rate-limiting, or +// sending logs to an external service. type Handler interface { Handle(context.Context, Entry) error Enabled(context.Context, Level) bool } -// NewSyncHandler returns a Handler that encodes entries in the calling -// goroutine. Encoding is fully parallel across goroutines — the Encoder -// handles internal cloning and buffer pooling. The provided io.Writer -// must be safe for concurrent use. +// NewSyncHandler returns the simplest possible Handler — it encodes each +// entry right there in the calling goroutine and writes it immediately. +// No routing, no buffering, no background goroutines. Think of it as the +// "just write it" handler. // -// This is the thinnest possible Handler — no routing, no buffering, -// no background goroutines. Useful for benchmarks where every -// nanosecond matters and for simple single-destination setups. +// Encoding is fully parallel across goroutines (the Encoder handles its +// own cloning and buffer pooling), but the provided io.Writer must be +// safe for concurrent use. Great for benchmarks, tests, and simple +// single-destination setups. func NewSyncHandler(level Level, w io.Writer, enc Encoder) Handler { return &syncHandler{level: level, w: w, enc: enc} } diff --git a/jsonencoder.go b/jsonencoder.go index b0cd5db..ad321c9 100644 --- a/jsonencoder.go +++ b/jsonencoder.go @@ -19,7 +19,10 @@ const ( DefaultFieldKeyCaller = "caller" ) -// JSONEncoderConfig allows to configure journal JSON Encoder. +// JSONEncoderConfig controls how the JSON encoder formats log entries — +// field keys, which fields to include, and how types like time, duration, +// and errors are rendered. For a friendlier builder-style API, use JSON() +// instead. type JSONEncoderConfig struct { FieldKeyMsg string FieldKeyTime string @@ -47,8 +50,9 @@ type JSONEncoderConfig struct { keyCaller []byte } -// WithDefaults returns the new config in which all uninitialized fields are -// filled with their default values. +// WithDefaults returns a copy of the config with all zero-value fields +// replaced by sensible defaults (RFC3339 timestamps, string durations, +// short caller format, etc.). func (c JSONEncoderConfig) WithDefaults() JSONEncoderConfig { // Handle default for predefined field names. if c.FieldKeyMsg == "" { @@ -93,106 +97,120 @@ func (c JSONEncoderConfig) WithDefaults() JSONEncoderConfig { return c } -// JSONEncoderBuilder configures and builds a JSON Encoder. -// Use NewJSONEncoder() to create one, chain setter methods, -// then call Build() or pass to LoggerBuilder.EncoderFrom(). +// JSONEncoderBuilder configures and builds a JSON Encoder using a clean +// builder-style API. Create one with JSON(), chain methods to customize, +// then call Build() or pass directly to LoggerBuilder.EncoderFrom(). type JSONEncoderBuilder struct { cfg JSONEncoderConfig } -// JSON returns a new JSONEncoderBuilder with default settings. +// JSON returns a new JSONEncoderBuilder with default settings. This is +// the recommended way to create a JSON encoder — chain the methods you +// need and call Build: // -// Default: // enc := logf.JSON().Build() -// -// Custom: // enc := logf.JSON().TimeKey("time").LevelKey("severity").Build() func JSON() *JSONEncoderBuilder { return &JSONEncoderBuilder{} } +// TimeKey sets the JSON key for the timestamp field (default "ts"). func (b *JSONEncoderBuilder) TimeKey(k string) *JSONEncoderBuilder { b.cfg.FieldKeyTime = k return b } +// LevelKey sets the JSON key for the severity level field (default "level"). func (b *JSONEncoderBuilder) LevelKey(k string) *JSONEncoderBuilder { b.cfg.FieldKeyLevel = k return b } +// MsgKey sets the JSON key for the log message field (default "msg"). func (b *JSONEncoderBuilder) MsgKey(k string) *JSONEncoderBuilder { b.cfg.FieldKeyMsg = k return b } +// NameKey sets the JSON key for the logger name field (default "logger"). func (b *JSONEncoderBuilder) NameKey(k string) *JSONEncoderBuilder { b.cfg.FieldKeyName = k return b } +// CallerKey sets the JSON key for the caller location field (default "caller"). func (b *JSONEncoderBuilder) CallerKey(k string) *JSONEncoderBuilder { b.cfg.FieldKeyCaller = k return b } +// DisableTime omits the timestamp field from JSON output entirely. func (b *JSONEncoderBuilder) DisableTime() *JSONEncoderBuilder { b.cfg.DisableFieldTime = true return b } +// DisableLevel omits the severity level field from JSON output entirely. func (b *JSONEncoderBuilder) DisableLevel() *JSONEncoderBuilder { b.cfg.DisableFieldLevel = true return b } +// DisableMsg omits the message text field from JSON output entirely. func (b *JSONEncoderBuilder) DisableMsg() *JSONEncoderBuilder { b.cfg.DisableFieldMsg = true return b } +// DisableName omits the logger name field from JSON output entirely. func (b *JSONEncoderBuilder) DisableName() *JSONEncoderBuilder { b.cfg.DisableFieldName = true return b } +// DisableCaller omits the caller location field from JSON output entirely. func (b *JSONEncoderBuilder) DisableCaller() *JSONEncoderBuilder { b.cfg.DisableFieldCaller = true return b } +// EncodeTime sets a custom TimeEncoder for formatting timestamps (default RFC3339). func (b *JSONEncoderBuilder) EncodeTime(e TimeEncoder) *JSONEncoderBuilder { b.cfg.EncodeTime = e return b } +// EncodeDuration sets a custom DurationEncoder for formatting durations (default string representation). func (b *JSONEncoderBuilder) EncodeDuration(e DurationEncoder) *JSONEncoderBuilder { b.cfg.EncodeDuration = e return b } +// EncodeLevel sets a custom LevelEncoder for formatting severity levels. func (b *JSONEncoderBuilder) EncodeLevel(e LevelEncoder) *JSONEncoderBuilder { b.cfg.EncodeLevel = e return b } +// EncodeCaller sets a custom CallerEncoder for formatting caller locations (default short format). func (b *JSONEncoderBuilder) EncodeCaller(e CallerEncoder) *JSONEncoderBuilder { b.cfg.EncodeCaller = e return b } +// EncodeError sets a custom ErrorEncoder for formatting error values. func (b *JSONEncoderBuilder) EncodeError(e ErrorEncoder) *JSONEncoderBuilder { b.cfg.EncodeError = e return b } -// Build finalizes the configuration and returns a ready Encoder. +// Build finalizes the configuration and returns a ready-to-use JSON Encoder. func (b *JSONEncoderBuilder) Build() Encoder { return buildJSONEncoder(b.cfg) } -// NewJSONEncoder creates a new JSON Encoder with the given config. -// For a builder-style API, use JSON() instead. +// NewJSONEncoder creates a JSON Encoder from a JSONEncoderConfig struct. +// For a friendlier builder-style API, use JSON() instead. func NewJSONEncoder(cfg JSONEncoderConfig) Encoder { return buildJSONEncoder(cfg) } @@ -243,7 +261,7 @@ func (f *jsonEncoder) Clone() Encoder { func (f *jsonEncoder) Encode(e Entry) (*Buffer, error) { clone := f.pool.Get().(*jsonEncoder) - + buf := GetBuffer() err := clone.encode(buf, e) @@ -652,8 +670,9 @@ func (f *jsonEncoder) addKey(k string) { const hex = "0123456789abcdef" -// EscapeString JSON-escapes s and appends the result to buf. -// s can be a string or []byte. +// EscapeString JSON-escapes s (which can be a string or []byte) and +// appends the result to buf. It handles control characters, backslash, +// quotes, and invalid UTF-8 sequences. func EscapeString[S []byte | string](buf *Buffer, s S) error { p := 0 for i := 0; i < len(s); { diff --git a/level.go b/level.go index 6a86437..4f41180 100644 --- a/level.go +++ b/level.go @@ -7,28 +7,32 @@ import ( "sync/atomic" ) -// Level defines severity level of a log message. +// Level represents the severity of a log message. Higher numeric values +// mean more verbose output — LevelDebug (3) lets everything through, +// while LevelError (0) only lets errors pass. type Level int8 // Severity levels. const ( - // LevelError allows to log errors only. + // LevelError logs errors only — the quietest setting. LevelError Level = iota - // LevelWarn allows to log errors and warnings. + // LevelWarn logs errors and warnings. LevelWarn - // LevelInfo is the default logging level. Allows to log errors, warnings and infos. + // LevelInfo logs errors, warnings, and informational messages. This is + // the typical production setting. LevelInfo - // LevelDebug allows to log messages with all severity levels. + // LevelDebug logs everything — all severity levels pass through. LevelDebug ) -// Enabled returns true if the given level is allowed within the current level. +// Enabled reports whether a message at level o would be logged under this +// level threshold. For example, LevelInfo.Enabled(LevelDebug) is false. func (l Level) Enabled(o Level) bool { return l >= o } -// String implements fmt.Stringer. -// String returns a lower-case string representation of the Level. +// String returns a lower-case string representation of the Level +// ("debug", "info", "warn", "error"). func (l Level) String() string { switch l { case LevelDebug: @@ -44,7 +48,8 @@ func (l Level) String() string { } } -// UpperCaseString returns an upper-case string representation of the Level. +// UpperCaseString returns an upper-case string representation of the Level +// ("DEBUG", "INFO", "WARN", "ERROR"). func (l Level) UpperCaseString() string { switch l { case LevelDebug: @@ -60,12 +65,13 @@ func (l Level) UpperCaseString() string { } } -// MarshalText marshals the Level to text. +// MarshalText marshals the Level to its lower-case text representation. func (l Level) MarshalText() ([]byte, error) { return []byte(l.String()), nil } -// UnmarshalText unmarshals the Level from text. +// UnmarshalText parses a level string (case-insensitive) and sets the Level. +// Returns an error for unrecognized values. func (l *Level) UnmarshalText(text []byte) error { s := string(text) lvl, ok := LevelFromString(s) @@ -78,7 +84,8 @@ func (l *Level) UnmarshalText(text []byte) error { return nil } -// LevelFromString creates the new Level with the given string. +// LevelFromString parses a level name (case-insensitive) and returns the +// corresponding Level. Returns false if the name is not recognized. func LevelFromString(lvl string) (Level, bool) { switch strings.ToLower(lvl) { case "debug": @@ -94,21 +101,25 @@ func LevelFromString(lvl string) (Level, bool) { return LevelError, false } -// LevelEncoder is the function type to encode Level. +// LevelEncoder is a function that formats a Level into the log output via +// TypeEncoder. Swap it out to control how levels appear in your logs. type LevelEncoder func(Level, TypeEncoder) -// DefaultLevelEncoder implements LevelEncoder by calling Level itself. +// DefaultLevelEncoder formats levels as lower-case strings ("debug", +// "info", "warn", "error"). This is the default for JSON output. func DefaultLevelEncoder(lvl Level, m TypeEncoder) { m.EncodeTypeString(lvl.String()) } -// UpperCaseLevelEncoder implements LevelEncoder by calling Level itself. +// UpperCaseLevelEncoder formats levels as upper-case strings ("DEBUG", +// "INFO", "WARN", "ERROR"). func UpperCaseLevelEncoder(lvl Level, m TypeEncoder) { m.EncodeTypeString(lvl.UpperCaseString()) } -// ShortTextLevelEncoder encodes Level as a 3-character uppercase string -// (DBG, INF, WRN, ERR). Intended for text/console output. +// ShortTextLevelEncoder formats levels as compact 3-character uppercase +// strings (DBG, INF, WRN, ERR). This is the default for text/console +// output where horizontal space is precious. func ShortTextLevelEncoder(lvl Level, m TypeEncoder) { switch lvl { case LevelDebug: @@ -124,31 +135,34 @@ func ShortTextLevelEncoder(lvl Level, m TypeEncoder) { } } -// NewMutableLevel creates an instance of MutableLevel with the given -// starting level. +// NewMutableLevel creates a MutableLevel starting at the given level. +// Pass it where a Level is expected and call Set later to change the +// threshold at runtime without restarting. func NewMutableLevel(l Level) *MutableLevel { return &MutableLevel{level: uint32(l)} } -// MutableLevel allows to switch the logging level atomically. -// -// The logger does not allow to change logging level in runtime by itself. +// MutableLevel is a concurrency-safe level that can be changed at runtime +// without rebuilding the Logger. Perfect for admin endpoints that toggle +// debug logging on a live system — just call Set and every subsequent +// log call picks up the new level atomically. type MutableLevel struct { level uint32 } -// Enabled reports whether the given level is enabled at the current -// mutable level. +// Enabled reports whether the given level is enabled at the current mutable +// level. Safe for concurrent use. func (l *MutableLevel) Enabled(_ context.Context, lvl Level) bool { return l.Level().Enabled(lvl) } -// Level returns the current logging level. +// Level returns the current logging level atomically. func (l *MutableLevel) Level() Level { return (Level)(atomic.LoadUint32(&l.level)) } -// Set switches the current logging level to the given one. +// Set atomically switches the logging level. All subsequent log calls +// will use the new threshold. func (l *MutableLevel) Set(o Level) { atomic.StoreUint32(&l.level, uint32(o)) } diff --git a/logger.go b/logger.go index 393559a..752d43b 100644 --- a/logger.go +++ b/logger.go @@ -6,9 +6,10 @@ import ( "time" ) -// New returns a new Logger with the given Handler. -// Level filtering is controlled by the handler's Enabled method. -// For a builder-style API, use NewLogger() instead. +// New returns a Logger wired to the given Handler. Level filtering is +// controlled by the handler's Enabled method, so you can use any Handler +// implementation — SyncHandler, Router, ContextHandler, or your own. +// For a friendlier builder-style API, use NewLogger() instead. func New(w Handler) *Logger { return &Logger{ w: w, @@ -16,16 +17,18 @@ func New(w Handler) *Logger { } } -// DisabledLogger returns a default instance of a Logger that logs nothing -// as fast as possible. +// DisabledLogger returns a Logger that silently discards everything as +// fast as possible. Handy as a safe default when no logger is configured, +// and it is what FromContext returns when there is no Logger in the context. func DisabledLogger() *Logger { return defaultDisabledLogger } -// Logger is the fast, asynchronous, structured logger. -// -// The Logger wraps Handler to check logging level and provide a bit of -// syntactic sugar. +// Logger is the main entry point for structured logging. It wraps a Handler, +// checks levels before doing any work, and provides the familiar +// Debug/Info/Warn/Error methods plus context-aware field accumulation via +// With and WithGroup. Loggers are immutable — every With/WithName/WithGroup +// call returns a new Logger, so they are safe to share across goroutines. type Logger struct { w Handler @@ -35,16 +38,18 @@ type Logger struct { callerSkip int } -// Enabled reports whether logging at the given level is enabled. +// Enabled reports whether logging at the given level would actually produce +// output. Use this to guard expensive argument preparation. func (l *Logger) Enabled(ctx context.Context, lvl Level) bool { return l.w.Enabled(ctx, lvl) } -// LogFunc allows to log a message with a bound level. +// LogFunc is a logging function with a pre-bound level, used by AtLevel. type LogFunc func(context.Context, string, ...Field) -// AtLevel calls the given fn if logging a message at the specified level -// is enabled, passing a LogFunc with the bound level. +// AtLevel calls fn only if the specified level is enabled, passing it a +// LogFunc pre-bound to that level. This is perfect for guarding expensive +// log preparation without a separate Enabled check. func (l *Logger) AtLevel(ctx context.Context, lvl Level, fn func(LogFunc)) { if !l.w.Enabled(ctx, lvl) { return @@ -55,9 +60,9 @@ func (l *Logger) AtLevel(ctx context.Context, lvl Level, fn func(LogFunc)) { }) } -// WithName returns a new Logger adding the given name to the calling one. -// Name separator is a period. -// +// WithName returns a new Logger with the given name appended to the +// existing name, separated by a period. Names appear in log output +// as "parent.child" and are great for identifying subsystems. // Loggers have no name by default. func (l *Logger) WithName(n string) *Logger { if n == "" { @@ -74,7 +79,8 @@ func (l *Logger) WithName(n string) *Logger { return cc } -// WithCaller returns a new Logger with caller reporting enabled or disabled. +// WithCaller returns a new Logger with caller reporting toggled on or off. +// When enabled (the default), every log entry includes the source file and line. func (l *Logger) WithCaller(enabled bool) *Logger { cc := l.clone() cc.addCaller = enabled @@ -82,8 +88,10 @@ func (l *Logger) WithCaller(enabled bool) *Logger { return cc } -// WithCallerSkip returns a new Logger with increased number of skipped -// frames. It's usable to build a custom wrapper for the Logger. +// WithCallerSkip returns a new Logger that skips additional stack frames +// when capturing caller info. Use this when you wrap the Logger in your +// own helper function so the reported caller points to your caller, not +// your wrapper. func (l *Logger) WithCallerSkip(skip int) *Logger { cc := l.clone() cc.callerSkip = skip @@ -91,7 +99,9 @@ func (l *Logger) WithCallerSkip(skip int) *Logger { return cc } -// With returns a new Logger with the given additional fields. +// With returns a new Logger that includes the given fields in every +// subsequent log entry. Fields are accumulated, not replaced — so +// calling With multiple times builds up context over time. func (l *Logger) With(fs ...Field) *Logger { cc := l.clone() cc.bag = l.bag.With(fs...) @@ -99,8 +109,9 @@ func (l *Logger) With(fs ...Field) *Logger { return cc } -// Slog returns a *slog.Logger that shares this Logger's writer, bag, -// and name. Level filtering is delegated to the same Handler. +// Slog returns a *slog.Logger backed by the same Handler, fields, and +// name as this Logger. Use it when you need to hand a standard library +// slog.Logger to code that does not know about logf. func (l *Logger) Slog() *slog.Logger { return slog.New(&slogHandler{ w: l.w, @@ -110,10 +121,11 @@ func (l *Logger) Slog() *slog.Logger { }) } -// WithGroup returns a new Logger that nests all subsequent fields -// (from With and per-call) under the given group name. -// Produces nested JSON objects: WithGroup("http") + Int("status", 200) -// → {"http":{"status":200}}. +// WithGroup returns a new Logger that nests all subsequent fields — both +// from With and from per-call arguments — under the given group name. +// In JSON output this produces nested objects: +// +// WithGroup("http") + Int("status", 200) → {"http":{"status":200}} func (l *Logger) WithGroup(name string) *Logger { if name == "" { return l @@ -125,8 +137,8 @@ func (l *Logger) WithGroup(name string) *Logger { return cc } -// Debug logs a debug message with the given text, optional fields and -// fields passed to the Logger using With function. +// Debug logs a message at LevelDebug. If debug logging is disabled, this +// is a no-op — no fields are evaluated, no allocations happen. func (l *Logger) Debug(ctx context.Context, text string, fs ...Field) { if !l.w.Enabled(ctx, LevelDebug) { return @@ -135,8 +147,8 @@ func (l *Logger) Debug(ctx context.Context, text string, fs ...Field) { l.write(ctx, 1, LevelDebug, text, fs) } -// Info logs an info message with the given text, optional fields and -// fields passed to the Logger using With function. +// Info logs a message at LevelInfo. This is the default "something +// happened" level for normal operational events. func (l *Logger) Info(ctx context.Context, text string, fs ...Field) { if !l.w.Enabled(ctx, LevelInfo) { return @@ -145,8 +157,8 @@ func (l *Logger) Info(ctx context.Context, text string, fs ...Field) { l.write(ctx, 1, LevelInfo, text, fs) } -// Warn logs a warning message with the given text, optional fields and -// fields passed to the Logger using With function. +// Warn logs a message at LevelWarn. Use this for situations that are +// unexpected but not broken — things a human should probably look at. func (l *Logger) Warn(ctx context.Context, text string, fs ...Field) { if !l.w.Enabled(ctx, LevelWarn) { return @@ -155,8 +167,8 @@ func (l *Logger) Warn(ctx context.Context, text string, fs ...Field) { l.write(ctx, 1, LevelWarn, text, fs) } -// Error logs an error message with the given text, optional fields and -// fields passed to the Logger using With function. +// Error logs a message at LevelError. Something went wrong and you want +// everyone to know about it. func (l *Logger) Error(ctx context.Context, text string, fs ...Field) { if !l.w.Enabled(ctx, LevelError) { return @@ -165,7 +177,8 @@ func (l *Logger) Error(ctx context.Context, text string, fs ...Field) { l.write(ctx, 1, LevelError, text, fs) } -// Log logs a message at the given level. +// Log logs a message at an arbitrary level. Use this when the level is +// determined at runtime; for the common cases prefer Debug/Info/Warn/Error. func (l *Logger) Log(ctx context.Context, lvl Level, text string, fs ...Field) { if !l.w.Enabled(ctx, lvl) { return @@ -200,9 +213,10 @@ func (l *Logger) clone() *Logger { } } -// LogDepth logs using the given logger at the specified level, adding depth -// extra frames to the caller skip count. It is intended for wrapper packages -// like logfc to avoid Logger allocation on each call. +// LogDepth logs at the given level, adding depth extra frames to the caller +// skip count. It is a package-level function (not a method) so that wrapper +// packages like logfc can log through an existing Logger without allocating +// a new one on every call. func LogDepth(l *Logger, ctx context.Context, depth int, lvl Level, text string, fs ...Field) { if !l.w.Enabled(ctx, lvl) { return @@ -211,13 +225,16 @@ func LogDepth(l *Logger, ctx context.Context, depth int, lvl Level, text string, l.write(ctx, depth+1, lvl, text, fs) } -// NewContext returns a new Context with the given Logger inside it. +// NewContext returns a new Context carrying the given Logger. Retrieve it +// later with FromContext. No more threading loggers through your entire +// call stack like some kind of dependency injection nightmare. func NewContext(parent context.Context, logger *Logger) context.Context { return context.WithValue(parent, contextKeyLogger{}, logger) } -// FromContext returns the Logger associated with this context or -// DisabledLogger() if no value is associated. +// FromContext returns the Logger stored in the context by NewContext, or +// a DisabledLogger if no Logger was stored. It is always safe to call — +// you will never get nil. func FromContext(ctx context.Context) *Logger { value := ctx.Value(contextKeyLogger{}) if value == nil { diff --git a/router.go b/router.go index 2e68595..7ea9833 100644 --- a/router.go +++ b/router.go @@ -7,12 +7,17 @@ import ( "sync" ) -// NewRouter returns a new RouterBuilder. +// NewRouter returns a RouterBuilder for constructing a fan-out Handler +// that sends log entries to multiple destinations. Each route groups +// outputs that share an encoder, so one Encode call serves all outputs +// in the group. func NewRouter() *RouterBuilder { return &RouterBuilder{} } -// RouterBuilder accumulates routes and builds a fan-out Handler. +// RouterBuilder accumulates routes and builds a fan-out Handler. Add as +// many routes as you need — each route has an encoder and one or more +// outputs with independent level filters. // // Usage: // @@ -27,8 +32,9 @@ type RouterBuilder struct { err error } -// Route adds an encoder group with the given outputs. All outputs in a -// group share one Encode call per entry. +// Route adds an encoder group with the given outputs. All outputs in the +// same route share a single Encode call per entry — so sending JSON to +// both a file and a network socket costs exactly one encode, not two. func (b *RouterBuilder) Route(enc Encoder, opts ...RouteOption) *RouterBuilder { g := encoderGroup{enc: enc} for _, opt := range opts { @@ -38,10 +44,10 @@ func (b *RouterBuilder) Route(enc Encoder, opts ...RouteOption) *RouterBuilder { return b } -// Build validates the configuration and returns the Router as a Handler. -// The returned close function calls Flush and Sync on all writers. -// Build returns an error if the configuration is invalid (no routes or -// no outputs). +// Build validates the configuration and returns the Router as a Handler, +// plus a close function that flushes and syncs all writers. Always defer +// the close function to ensure data reaches its destination. Build returns +// an error if the configuration is invalid (no routes or no outputs). func (b *RouterBuilder) Build() (Handler, func() error, error) { if b.err != nil { return nil, nil, b.err @@ -108,12 +114,13 @@ type output struct { closeFn func() error // non-nil if the writer should be closed } -// RouteOption configures a route within an encoder group. +// RouteOption configures a destination within a Route's encoder group. +// Use Output and OutputCloser to create RouteOptions. type RouteOption func(*encoderGroup) // Output returns a RouteOption that adds a destination with the given -// level filter and writer. The caller goroutine writes encoded data -// directly — no channel, no consumer goroutine, zero per-message +// level filter and writer. Writes happen directly in the caller's +// goroutine — no channel, no background goroutine, zero per-message // allocations. The Writer must be safe for concurrent use. // // For async I/O with batching and spike tolerance, wrap the writer in @@ -130,9 +137,10 @@ func Output(level Level, w io.Writer) RouteOption { } } -// OutputCloser is like Output but the router's close function will call -// Close on w after flushing. Use this to transfer ownership of a -// SlabWriter (or any other resource) to the router: +// OutputCloser is like Output but transfers ownership of the writer to +// the router — the router's close function will call Close on w after +// flushing. Perfect for SlabWriters and other resources you want the +// router to manage: // // sw := logf.NewSlabWriter(conn, 64*1024, 8) // router, close, _ := logf.NewRouter(). diff --git a/setup.go b/setup.go index 6bd6e99..3d6a9f9 100644 --- a/setup.go +++ b/setup.go @@ -5,9 +5,10 @@ import ( "os" ) -// NewLogger returns a LoggerBuilder for constructing a Logger with a -// single-destination sync pipeline. For async buffered or multi-destination -// setups use NewRouter + SlabWriter. +// NewLogger returns a LoggerBuilder — the easiest way to get a Logger up +// and running. It builds a single-destination sync pipeline, which is +// perfect for most applications. For async buffered or multi-destination +// setups, reach for NewRouter + SlabWriter instead. // // Defaults: JSON encoder, LevelDebug, os.Stderr, caller enabled, // no ContextHandler. @@ -26,7 +27,9 @@ func NewLogger() *LoggerBuilder { } // LoggerBuilder accumulates options and builds a Logger with a sync -// pipeline: Encoder → SyncHandler → [ContextHandler] → Logger. +// pipeline: Encoder -> SyncHandler -> [ContextHandler] -> Logger. +// Chain its methods and finish with Build. For advanced multi-destination +// pipelines, use NewRouter directly. type LoggerBuilder struct { level Level enc Encoder @@ -36,27 +39,31 @@ type LoggerBuilder struct { sources []FieldSource } -// Level sets the minimum logging level. Default is LevelDebug. +// Level sets the minimum severity level. Messages below this level are +// discarded. Default is LevelDebug (everything gets through). func (b *LoggerBuilder) Level(l Level) *LoggerBuilder { b.level = l return b } -// Output sets the output writer. Default is os.Stderr. +// Output sets where encoded log entries are written. Default is os.Stderr. func (b *LoggerBuilder) Output(w io.Writer) *LoggerBuilder { b.w = w return b } -// Encoder sets a pre-built Encoder. +// Encoder sets a pre-built Encoder directly. Use this when you already +// have an Encoder instance; otherwise prefer EncoderFrom for builder +// composition. func (b *LoggerBuilder) Encoder(enc Encoder) *LoggerBuilder { b.enc = enc b.encB = nil return b } -// EncoderFrom sets an EncoderBuilder whose BuildEncoder will be called -// during Build. This enables builder composition: +// EncoderFrom sets an EncoderBuilder whose Build method will be called +// when LoggerBuilder.Build is called. This enables clean builder +// composition — no need to call Build on the encoder separately: // // logf.NewLogger().EncoderFrom(logf.JSON().TimeKey("time")).Build() func (b *LoggerBuilder) EncoderFrom(eb EncoderBuilder) *LoggerBuilder { @@ -65,16 +72,18 @@ func (b *LoggerBuilder) EncoderFrom(eb EncoderBuilder) *LoggerBuilder { return b } -// Context enables ContextHandler which extracts Bag fields from context -// on each log entry. Optional FieldSource functions add external field -// extraction (e.g. trace IDs, request metadata). +// Context enables the ContextHandler middleware, which extracts Bag fields +// from context on every log call. This is what makes logf.With(ctx, ...) +// work — without it, context fields are silently ignored. Optional +// FieldSource functions let you pull in external fields too (trace IDs, +// request metadata, etc.). func (b *LoggerBuilder) Context(sources ...FieldSource) *LoggerBuilder { b.context = true b.sources = append(b.sources, sources...) return b } -// Build finalizes the configuration and returns a ready Logger. +// Build finalizes the configuration and returns a ready-to-use Logger. // // logger := logf.NewLogger().Build() func (b *LoggerBuilder) Build() *Logger { diff --git a/slabwriter.go b/slabwriter.go index 246bd9e..ee7730c 100644 --- a/slabwriter.go +++ b/slabwriter.go @@ -14,8 +14,10 @@ const ( defaultSlabCount = 4 ) -// SlabWriter is an async buffered writer that decouples the caller -// from I/O by using a pool of pre-allocated linear byte slabs. +// SlabWriter is an async buffered writer that keeps your logging goroutines +// fast by decoupling them from slow I/O. It uses a pool of pre-allocated +// linear byte slabs — producers memcpy into a slab, and a background +// goroutine writes full slabs to the destination in big, efficient batches. // // Architecture: // @@ -143,7 +145,9 @@ type SlabWriter struct { writeErrors atomic.Int64 // total write errors (ioLoop only) } -// SlabStats contains runtime statistics for monitoring. +// SlabStats is a snapshot of SlabWriter runtime statistics. Pull it from +// Stats() and feed it to your metrics system to keep an eye on queue +// depth, drop rates, and write errors. type SlabStats struct { QueuedSlabs int // slabs waiting for I/O FreeSlabs int // slabs available in pool @@ -153,32 +157,37 @@ type SlabStats struct { WriteErrors int64 // total write errors } -// SlabOption configures a SlabWriter. +// SlabOption configures a SlabWriter at creation time. Pass options to +// NewSlabWriter to customize flush behavior, drop policy, and error +// reporting. type SlabOption func(*SlabWriter) -// WithFlushInterval sets the idle flush interval. When no new data -// arrives for this duration, the partial slab is flushed to the -// destination. Default is 0 (no idle flush). +// WithFlushInterval sets how long the SlabWriter waits for new data +// before flushing a partial slab. Without this, a quiet period could +// leave recent log entries sitting in the buffer. Default is 0 (no +// idle flush — data only goes out when a slab fills up or Close is +// called). func WithFlushInterval(d time.Duration) SlabOption { return func(sw *SlabWriter) { sw.flushInterval = d } } -// WithDropOnFull makes Write non-blocking: when the I/O goroutine -// cannot keep up and all slabs are in flight, the current slab's -// data is dropped instead of blocking the caller. The total number -// of dropped messages is available via Dropped. +// WithDropOnFull makes Write non-blocking: if the I/O goroutine cannot +// keep up and all slabs are in flight, the current slab's data is +// silently dropped instead of blocking the caller. Use this when you +// would rather lose log messages than add latency to your hot path. +// Monitor dropped messages via Stats().Dropped. func WithDropOnFull() SlabOption { return func(sw *SlabWriter) { sw.dropOnFull = true } } -// WithErrorWriter sets a writer for I/O error reports. When the -// background goroutine fails to write a slab to the destination, it -// formats the error and writes it to w. By default errors are -// silently discarded. Typical usage: WithErrorWriter(os.Stderr). +// WithErrorWriter sets where I/O errors are reported. When the background +// goroutine fails to write a slab, it formats the error and writes it +// to w. By default errors are silently discarded — pass os.Stderr here +// if you want to know about write failures. func WithErrorWriter(w io.Writer) SlabOption { return func(sw *SlabWriter) { sw.errW = w @@ -186,8 +195,9 @@ func WithErrorWriter(w io.Writer) SlabOption { } // NewSlabWriter creates a SlabWriter that buffers writes into pre-allocated -// slabs and flushes them to w via a background I/O goroutine. Close must -// be called to flush remaining data and stop the goroutine. +// slabs and flushes them to w via a background I/O goroutine. You must +// call Close when you are done to flush remaining data and stop the +// goroutine — a defer sw.Close() right after creation is the way to go. func NewSlabWriter(w io.Writer, slabSize, slabCount int, opts ...SlabOption) *SlabWriter { if slabSize <= 0 { slabSize = defaultSlabSize @@ -218,12 +228,10 @@ func NewSlabWriter(w io.Writer, slabSize, slabCount int, opts ...SlabOption) *Sl return sb } -// Write copies p into the current slab. Each message is guaranteed to -// be either fully written or fully dropped (see Message integrity above). -// -// If p does not fit in the remaining slab space, an early swap is -// performed so the message lands in a fresh slab. If p is larger than -// slabSize, a dedicated oversized buffer is allocated. +// Write copies p into the current slab. Every message is guaranteed to be +// either fully written or fully dropped — never partially torn. If the +// message does not fit in the remaining slab space, an early swap puts it +// in a fresh slab. Messages larger than slabSize get a dedicated buffer. // // Write is safe for concurrent use. It must not be called after Close. func (sb *SlabWriter) Write(p []byte) (int, error) { @@ -257,9 +265,10 @@ func (sb *SlabWriter) Write(p []byte) (int, error) { return len(p), nil } -// Flush enqueues the current partial slab for writing by the I/O -// goroutine. It does not wait for the write to complete. For a -// durable flush, use Close. It must not be called after Close. +// Flush enqueues the current partial slab for writing by the background +// I/O goroutine. It returns immediately without waiting for the write to +// complete — if you need a durable flush, use Close instead. Must not be +// called after Close. func (sb *SlabWriter) Flush() error { sb.mu.Lock() if sb.pos > 0 { @@ -269,14 +278,15 @@ func (sb *SlabWriter) Flush() error { return nil } -// Sync is a no-op. The underlying writer's Sync is called on Close. +// Sync is a no-op on SlabWriter — the real Sync on the underlying writer +// happens during Close. func (sb *SlabWriter) Sync() error { return nil } -// Close flushes remaining data, stops the I/O goroutine, and calls -// Flush and Sync on the underlying Writer. It is safe to call -// multiple times; subsequent calls return the same error. +// Close flushes remaining data, drains the queue, stops the background +// I/O goroutine, and calls Flush + Sync on the underlying Writer. Safe +// to call multiple times — subsequent calls return the same error. func (sb *SlabWriter) Close() error { sb.closeOnce.Do(func() { sb.mu.Lock() @@ -292,10 +302,8 @@ func (sb *SlabWriter) Close() error { return sb.closeErr } -// Stats returns a snapshot of runtime statistics. Safe to call -// concurrently from a metrics scraper. Briefly locks mu to read -// the written counter (plain int64, not atomic — atomic.Add inside -// mutex causes cache-line bouncing under parallel load, +22% regression). +// Stats returns a point-in-time snapshot of runtime statistics. Safe to +// call concurrently from a metrics scraper or health check endpoint. func (sb *SlabWriter) Stats() SlabStats { sb.mu.Lock() written := sb.written diff --git a/slog.go b/slog.go index 24f35d8..223a585 100644 --- a/slog.go +++ b/slog.go @@ -7,15 +7,14 @@ import ( "unsafe" ) -// NewSlogHandler returns a [slog.Handler] that writes log records -// to the given [Handler]. +// NewSlogHandler returns a [slog.Handler] that bridges the standard library's +// slog package to logf's pipeline. Use this when you want third-party code +// that speaks slog to flow through your logf Handler, Encoder, and Writer +// setup. // -// Fields added with [slog.Logger.With] become [Entry.LoggerBag], -// which the JSON encoder caches per unique Bag version. -// Each call to WithAttrs allocates a new Bag automatically. -// -// The handler propagates context to [Handler.Handle], -// so field bags attached via [With] are resolved by [NewContextHandler]. +// Fields added with [slog.Logger.With] become [Entry.LoggerBag] (cached by +// the encoder). The handler propagates context to [Handler.Handle], so +// field bags attached via [With] are resolved by [NewContextHandler]. func NewSlogHandler(w Handler) slog.Handler { return &slogHandler{w: w, addCaller: true} } diff --git a/textencoder.go b/textencoder.go index 51065fd..804d25c 100644 --- a/textencoder.go +++ b/textencoder.go @@ -8,7 +8,10 @@ import ( "unsafe" ) -// TextEncoderConfig allows to configure the text Encoder. +// TextEncoderConfig controls how the text encoder formats log entries — +// colors, which fields to show, and how types like time, duration, and +// errors are rendered. For a friendlier builder-style API, use Text() +// instead. type TextEncoderConfig struct { NoColor bool DisableFieldTime bool @@ -24,8 +27,9 @@ type TextEncoderConfig struct { EncodeCaller CallerEncoder } -// WithDefaults returns the new config in which all uninitialized fields -// are filled with their default values. +// WithDefaults returns a copy of the config with all zero-value fields +// replaced by sensible defaults (StampMilli timestamps, string durations, +// short caller format, short level names, etc.). func (c TextEncoderConfig) WithDefaults() TextEncoderConfig { if c.EncodeDuration == nil { c.EncodeDuration = StringDurationEncoder @@ -45,19 +49,22 @@ func (c TextEncoderConfig) WithDefaults() TextEncoderConfig { return c } -// NewTextEncoder creates a new text Encoder with the given config. +// NewTextEncoder creates a text Encoder from a TextEncoderConfig struct. +// For a friendlier builder-style API, use Text() instead. func NewTextEncoder(cfg TextEncoderConfig) Encoder { return buildTextEncoder(cfg) } -// Text returns a new TextEncoderBuilder with default settings. -// Colors are enabled by default. Use NoColor() to disable, or -// check the NO_COLOR environment variable (https://no-color.org): +// Text returns a new TextEncoderBuilder — the recommended way to create a +// human-readable text encoder with ANSI colors. Colors are on by default; +// use NoColor() to disable them or check the NO_COLOR environment variable +// (https://no-color.org): // // enc := logf.Text().Build() // enc := logf.Text().NoColor().Build() // -// Respect NO_COLOR convention: +// Respect the NO_COLOR convention: +// // b := logf.Text() // if _, ok := os.LookupEnv("NO_COLOR"); ok { // b = b.NoColor() @@ -66,67 +73,81 @@ func Text() *TextEncoderBuilder { return &TextEncoderBuilder{} } -// TextEncoderBuilder configures and builds a text Encoder. +// TextEncoderBuilder configures and builds a text Encoder using a clean +// builder-style API. Create one with Text(), chain methods to customize, +// then call Build(). type TextEncoderBuilder struct { cfg TextEncoderConfig } +// NoColor disables ANSI color escape sequences in the output. +// Use this when writing to files or non-TTY destinations. func (b *TextEncoderBuilder) NoColor() *TextEncoderBuilder { b.cfg.NoColor = true return b } +// DisableTime omits the timestamp from text output entirely. func (b *TextEncoderBuilder) DisableTime() *TextEncoderBuilder { b.cfg.DisableFieldTime = true return b } +// DisableLevel omits the severity level from text output entirely. func (b *TextEncoderBuilder) DisableLevel() *TextEncoderBuilder { b.cfg.DisableFieldLevel = true return b } +// DisableMsg omits the message text from text output entirely. func (b *TextEncoderBuilder) DisableMsg() *TextEncoderBuilder { b.cfg.DisableFieldMsg = true return b } +// DisableName omits the logger name from text output entirely. func (b *TextEncoderBuilder) DisableName() *TextEncoderBuilder { b.cfg.DisableFieldName = true return b } +// DisableCaller omits the caller location from text output entirely. func (b *TextEncoderBuilder) DisableCaller() *TextEncoderBuilder { b.cfg.DisableFieldCaller = true return b } +// EncodeTime sets a custom TimeEncoder for formatting timestamps (default time.StampMilli). func (b *TextEncoderBuilder) EncodeTime(e TimeEncoder) *TextEncoderBuilder { b.cfg.EncodeTime = e return b } +// EncodeDuration sets a custom DurationEncoder for formatting durations (default string representation). func (b *TextEncoderBuilder) EncodeDuration(e DurationEncoder) *TextEncoderBuilder { b.cfg.EncodeDuration = e return b } +// EncodeLevel sets a custom LevelEncoder for formatting severity levels. func (b *TextEncoderBuilder) EncodeLevel(e LevelEncoder) *TextEncoderBuilder { b.cfg.EncodeLevel = e return b } +// EncodeCaller sets a custom CallerEncoder for formatting caller locations (default short format). func (b *TextEncoderBuilder) EncodeCaller(e CallerEncoder) *TextEncoderBuilder { b.cfg.EncodeCaller = e return b } +// EncodeError sets a custom ErrorEncoder for formatting error values. func (b *TextEncoderBuilder) EncodeError(e ErrorEncoder) *TextEncoderBuilder { b.cfg.EncodeError = e return b } -// Build finalizes the configuration and returns a ready Encoder. +// Build finalizes the configuration and returns a ready-to-use text Encoder. func (b *TextEncoderBuilder) Build() Encoder { return buildTextEncoder(b.cfg) } diff --git a/time.go b/time.go index 73c9854..b40e700 100644 --- a/time.go +++ b/time.go @@ -6,13 +6,16 @@ import ( "unsafe" ) -// TimeEncoder is the function type to encode the given Time. +// TimeEncoder is a function that formats a time.Time into the log output +// via the TypeEncoder. Swap it out to control timestamp format globally. type TimeEncoder func(time.Time, TypeEncoder) -// DurationEncoder is the function type to encode the given Duration. +// DurationEncoder is a function that formats a time.Duration into the log +// output via the TypeEncoder. type DurationEncoder func(time.Duration, TypeEncoder) -// RFC3339TimeEncoder encodes the given Time as a string using RFC3339 layout. +// RFC3339TimeEncoder formats timestamps as RFC3339 strings (e.g. +// "2006-01-02T15:04:05Z07:00"). This is the default for JSON output. func RFC3339TimeEncoder(t time.Time, e TypeEncoder) { var timeBuf [64]byte var b []byte @@ -22,8 +25,8 @@ func RFC3339TimeEncoder(t time.Time, e TypeEncoder) { runtime.KeepAlive(&b) } -// RFC3339NanoTimeEncoder encodes the given Time as a string using -// RFC3339Nano layout. +// RFC3339NanoTimeEncoder formats timestamps as RFC3339 strings with +// nanosecond precision (e.g. "2006-01-02T15:04:05.999999999Z07:00"). func RFC3339NanoTimeEncoder(t time.Time, e TypeEncoder) { var timeBuf [64]byte var b []byte @@ -33,7 +36,8 @@ func RFC3339NanoTimeEncoder(t time.Time, e TypeEncoder) { runtime.KeepAlive(&b) } -// LayoutTimeEncoder encodes the given Time as a string using custom layout. +// LayoutTimeEncoder returns a TimeEncoder that formats timestamps using the +// given Go time layout string (same format as time.Format). func LayoutTimeEncoder(layout string) TimeEncoder { return func(t time.Time, m TypeEncoder) { var timeBuf [64]byte @@ -45,25 +49,26 @@ func LayoutTimeEncoder(layout string) TimeEncoder { } } -// UnixNanoTimeEncoder encodes the given Time as a Unix time, the number of -// of nanoseconds elapsed since January 1, 1970 UTC. +// UnixNanoTimeEncoder formats timestamps as integer nanoseconds since the +// Unix epoch. Compact and machine-friendly, but not human-readable. func UnixNanoTimeEncoder(t time.Time, e TypeEncoder) { e.EncodeTypeInt64(t.UnixNano()) } -// NanoDurationEncoder encodes the given Duration as a number of nanoseconds. +// NanoDurationEncoder formats durations as integer nanoseconds. func NanoDurationEncoder(d time.Duration, e TypeEncoder) { e.EncodeTypeInt64(int64(d)) } -// FloatSecondsDurationEncoder encodes the given Duration to a floating-point -// number of seconds elapsed. +// FloatSecondsDurationEncoder formats durations as floating-point seconds +// (e.g. 1.5 for one and a half seconds). func FloatSecondsDurationEncoder(d time.Duration, e TypeEncoder) { e.EncodeTypeFloat64(float64(d) / float64(time.Second)) } -// StringDurationEncoder encodes the given Duration as a human-readable -// string (e.g. "4.5s", "300ms", "1h2m3s") without allocating. +// StringDurationEncoder formats durations as human-readable strings like +// "4.5s", "300ms", or "1h2m3s" — the same format as time.Duration.String() +// but without allocating. This is the default. func StringDurationEncoder(d time.Duration, m TypeEncoder) { var buf [32]byte var b []byte diff --git a/writer.go b/writer.go index 7c6b116..3ef9136 100644 --- a/writer.go +++ b/writer.go @@ -6,24 +6,24 @@ import ( "syscall" ) -// Writer extends io.Writer with Flush and Sync operations. -// Flush writes any buffered data to the underlying output. -// Sync commits the written data to stable storage (e.g. fsync). -// -// Router calls Flush when its channel is empty (catch-up moment) -// and Sync on close. +// Writer extends io.Writer with Flush and Sync — the two operations +// needed for reliable log delivery. Flush pushes buffered data to the +// underlying output, and Sync commits it to stable storage (think +// fsync). The Router calls Flush and Sync during its close sequence +// to make sure nothing is left in flight. type Writer interface { io.Writer Flush() error Sync() error } -// WriterFromIO wraps a plain io.Writer into a Writer. -// If w already implements Writer, it is returned as-is. -// Otherwise, Flush and Sync are derived from the underlying type: +// WriterFromIO upgrades a plain io.Writer to a Writer. If w already +// implements Writer, it is returned as-is — no wrapping overhead. +// Otherwise, the wrapper discovers Flush and Sync capabilities from +// the underlying type: // - Sync calls w.Sync() if available (e.g. *os.File) // - Flush calls w.Flush() if available (e.g. *bufio.Writer) -// - Missing methods become no-ops. +// - Missing methods become no-ops func WriterFromIO(w io.Writer) Writer { if sw, ok := w.(Writer); ok { return sw diff --git a/writerslot.go b/writerslot.go index 7ec0737..2a80501 100644 --- a/writerslot.go +++ b/writerslot.go @@ -6,8 +6,9 @@ import ( "sync/atomic" ) -// NewWriterSlot returns a new WriterSlot. By default, writes before -// Set are silently dropped. +// NewWriterSlot returns a new WriterSlot ready for use. Before Set is +// called, writes are either silently dropped or buffered (if you pass +// WithSlotBuffer). func NewWriterSlot(opts ...WriterSlotOption) *WriterSlot { s := &WriterSlot{} for _, opt := range opts { @@ -16,20 +17,23 @@ func NewWriterSlot(opts ...WriterSlotOption) *WriterSlot { return s } -// WriterSlot is a placeholder Writer that can be connected to a real -// writer later via Set. Before Set is called, writes are either dropped -// or buffered (if WithSlotBuffer was used). +// WriterSlot is a placeholder Writer you can wire into a Logger now and +// connect to a real destination later via Set. This solves the +// chicken-and-egg problem where you need a Logger at startup but the +// actual output (file, network, etc.) is not ready yet. // -// WriterSlot implements Writer (io.Writer + Flush + Sync) and is safe -// for concurrent use. Use it when the destination is not available at -// logger creation time: +// Before Set is called, writes are either dropped or buffered (if +// WithSlotBuffer was used). After Set, all writes go straight to the +// real writer with no extra overhead. // // slot := logf.NewWriterSlot() // logger := logf.NewLogger().Output(slot).Build() // // ... later, when destination is ready: // slot.Set(file) // -// Set is NOT safe for concurrent calls — call it from a single goroutine. +// WriterSlot is safe for concurrent Write/Flush/Sync calls. +// Set itself is NOT safe for concurrent calls — call it from a single +// goroutine. type WriterSlot struct { w atomic.Pointer[writerRef] flushed atomic.Bool @@ -41,13 +45,13 @@ type WriterSlot struct { // writerRef wraps Writer to avoid double indirection through atomic.Pointer. type writerRef struct{ w Writer } -// WriterSlotOption configures a WriterSlot. +// WriterSlotOption configures a WriterSlot at creation time. type WriterSlotOption func(*WriterSlot) -// WithSlotBuffer enables buffering of writes before Set is called. -// Up to size bytes are kept in memory. Writes that do not fit entirely -// are dropped (no partial writes). The buffer is flushed to the real -// writer on the first Write after Set. +// WithSlotBuffer enables buffering of early writes before Set is called, +// keeping up to size bytes in memory so you do not lose startup logs. +// Writes that do not fit entirely are dropped (no partial writes). The +// buffer is flushed to the real writer on the first Write after Set. func WithSlotBuffer(size int) WriterSlotOption { return func(s *WriterSlot) { if size > 0 { @@ -57,8 +61,8 @@ func WithSlotBuffer(size int) WriterSlotOption { } } -// Write writes p to the underlying writer if Set has been called. -// Before Set, data is buffered (if configured) or dropped. +// Write writes p to the real writer if Set has been called. Before Set, +// data is buffered (if WithSlotBuffer was used) or silently dropped. func (s *WriterSlot) Write(p []byte) (int, error) { if ref := s.w.Load(); ref != nil { if s.bufSize > 0 && !s.flushed.Load() { @@ -102,7 +106,7 @@ func (s *WriterSlot) flushPending(w Writer) { s.mu.Unlock() } -// Flush delegates to the underlying writer's Flush. No-op before Set. +// Flush delegates to the real writer's Flush. No-op before Set. func (s *WriterSlot) Flush() error { if ref := s.w.Load(); ref != nil { return ref.w.Flush() @@ -110,7 +114,7 @@ func (s *WriterSlot) Flush() error { return nil } -// Sync delegates to the underlying writer's Sync. No-op before Set. +// Sync delegates to the real writer's Sync. No-op before Set. func (s *WriterSlot) Sync() error { if ref := s.w.Load(); ref != nil { return ref.w.Sync() @@ -118,12 +122,12 @@ func (s *WriterSlot) Sync() error { return nil } -// Set connects the slot to a real writer. The buffered data (if any) -// will be flushed on the first Write call after Set — this preserves -// temporal ordering without blocking Set itself. +// Set connects the slot to a real writer. Any buffered data will be +// flushed on the next Write call, preserving temporal ordering without +// blocking Set itself. The writer is automatically wrapped via +// WriterFromIO if needed. // -// The writer is wrapped via WriterFromIO if needed. -// Set is NOT safe for concurrent calls. +// Set is NOT safe for concurrent calls — call it from a single goroutine. func (s *WriterSlot) Set(w io.Writer) { prev := s.w.Swap(&writerRef{WriterFromIO(w)}) if prev != nil {