diff --git a/.changeset/dx-loglayer-feedback.md b/.changeset/dx-loglayer-feedback.md new file mode 100644 index 0000000..9156441 --- /dev/null +++ b/.changeset/dx-loglayer-feedback.md @@ -0,0 +1,16 @@ +--- +"go.loglayer.dev": minor +"transports/cli": minor +--- + +DX improvements from hmn-cli migration feedback. + +`loglayer`: + +- **`Config.Level` initial threshold**: set the minimum level at construction via `Config.Level`, applied exactly like `SetLevel`. Zero means "no override": every level stays enabled (the previous behavior). Composes with `Disabled`. See [Level](/configuration#level). +- **`WithStdlibContext` alias**: `WithContext` is now also reachable as `WithStdlibContext` on both `*LogLayer` and `*LogBuilder`, for discoverability when searching for "context". `WithContext` remains canonical. See [Go Context](/logging-api/go-context). + +`transports/cli`: + +- **Per-stream TTY detection in `ColorAuto`**: info / debug / trace lines follow stdout's TTY status; warn / error / fatal / panic lines follow stderr's. Piping stdout (e.g. `cli ... | less`) no longer strips color from severity lines that are still attached to a terminal. Resolution is pinned at construction. See [CLI Transport](/transports/cli#color-auto-always-never). +- **`Config.MessageFn` full-line takeover**: a callback that replaces the message plus the logfmt / table body with a single user-controlled string. The level prefix, its color, and the user prefix still apply; an empty return falls back to normal rendering. See [MessageFn](/transports/cli#messagefn). diff --git a/.changeset/gcplogging-grpc-vuln-fix.md b/.changeset/gcplogging-grpc-vuln-fix.md new file mode 100644 index 0000000..5ee298a --- /dev/null +++ b/.changeset/gcplogging-grpc-vuln-fix.md @@ -0,0 +1,8 @@ +--- +"transports/gcplogging": patch +--- + +Bump `google.golang.org/grpc` from v1.79.3 to v1.82.1 (plus transitive +upgrades) to clear GO-2026-6061 (xDS RBAC authorization + HTTP/2 +transport server vulnerabilities), which was reachable from +`SendToLogger` / `reportError`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04c5b99..ed60284 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: name: Govulncheck runs-on: ubuntu-latest env: - GOVULNCHECK_VERSION: 'v1.1.4' + GOVULNCHECK_VERSION: 'v1.7.0' steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -148,5 +148,9 @@ jobs: - name: Install govulncheck if: steps.cache-govulncheck.outputs.cache-hit != 'true' run: go install golang.org/x/vuln/cmd/govulncheck@${{ env.GOVULNCHECK_VERSION }} - - name: Run govulncheck - run: scripts/foreach-module.sh vuln + # Mirrors the local pre-push gate (scripts/govulncheck-gate.sh): + # fails on reachable dependency vulnerabilities, which the repo + # can fix by bumping the dep; reports stdlib findings (fixed only + # by upgrading the Go toolchain) as advisory, not gating. + - name: Run govulncheck gate + run: bash scripts/govulncheck-gate.sh diff --git a/README.md b/README.md index d780123..f54c5c3 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,11 @@ log.WithPrefix("[my-app]"). "path": "/", "reqId": "1234" }, - "metadata": { - "some": "data" - }, "err": { "message": "test" + }, + "metadata": { + "some": "data" } } ``` diff --git a/builder.go b/builder.go index 662b8bc..4811c2a 100644 --- a/builder.go +++ b/builder.go @@ -58,6 +58,13 @@ func (b *LogBuilder) WithContext(ctx context.Context) *LogBuilder { return b } +// WithStdlibContext is an alias for WithContext on the builder, mirroring +// (*LogLayer).WithStdlibContext. It spells out that the argument is the +// stdlib context.Context, distinct from loglayer.Fields. +func (b *LogBuilder) WithStdlibContext(ctx context.Context) *LogBuilder { + return b.WithContext(ctx) +} + // WithGroup tags this single log entry with one or more group names. // Routing rules in Config.Groups decide which transports receive the // entry. Tags are merged with any persistent groups assigned via diff --git a/coverage_test.go b/coverage_test.go index ef8c3a2..5439a8b 100644 --- a/coverage_test.go +++ b/coverage_test.go @@ -210,6 +210,46 @@ func TestWithContext_PerCallOverridesBound(t *testing.T) { } } +// WithStdlibContext is an alias for WithContext: same derived-logger +// semantics (bind to every subsequent emission, per-call override wins, nil +// clears, receiver unchanged). +func TestWithStdlibContext_AliasBehavior(t *testing.T) { + log, lib := setup(t) + type ctxKey struct{} + ctx := context.WithValue(context.Background(), ctxKey{}, "stdlib-ctx") + + logger := log.WithStdlibContext(ctx) + logger.Info("bound via alias") + if got := lib.PopLine().Ctx.Value(ctxKey{}); got != "stdlib-ctx" { + t.Errorf("alias should bind ctx: got %v", got) + } + + _ = log.WithStdlibContext(ctx) // no assignment + log.Info("receiver unchanged") + if got := lib.PopLine(); got.Ctx != nil { + t.Errorf("receiver must not be affected by alias call: got %v", got.Ctx) + } + + cleared := logger.WithStdlibContext(context.WithValue(context.Background(), ctxKey{}, "replacement")) + cleared.Info("replacement") + if got := lib.PopLine(); got.Ctx.Value(ctxKey{}) != "replacement" { + t.Errorf("replacement ctx should attach: got %v", got.Ctx) + } +} + +// WithStdlibContext works on the builder too: per-call override for one +// emission, mirroring (*LogBuilder).WithContext. +func TestWithStdlibContext_BuilderAlias(t *testing.T) { + log, lib := setup(t) + type ctxKey struct{} + override := context.WithValue(context.Background(), ctxKey{}, "OVERRIDE") + + log.WithStdlibContext(override).Info("one emission") + if got := lib.PopLine().Ctx.Value(ctxKey{}); got != "OVERRIDE" { + t.Errorf("builder alias should attach ctx per call: got %v", got) + } +} + // WithContext returns a derived logger; the receiver's behavior is unchanged. func TestWithContext_ReceiverUnchanged(t *testing.T) { log, lib := setup(t) diff --git a/docs/src/cheatsheet.md b/docs/src/cheatsheet.md index 38a9837..060fddd 100644 --- a/docs/src/cheatsheet.md +++ b/docs/src/cheatsheet.md @@ -32,7 +32,8 @@ LogLayer uses two distinct method patterns. Knowing which is which avoids one of | Prefix | Pattern | Example | |---|---|---| | `With*` | Returns a **new logger or builder**. The receiver is unchanged; **assign the return value** or your change is lost. | `log = log.WithFields(...)` | -| `Mute`, `Unmute`, `Set`, `Enable`, `Disable`, `Add`, `Remove` | Mutates the receiver in place. Returns `*LogLayer` for chaining; the return value is the same instance. | `log.MuteFields()` | +| `Mute`, `Unmute`, `Set`, `Enable`, `Disable`, `Add` | Mutates the receiver in place. Returns `*LogLayer` for chaining; the return value is the same instance. | `log.MuteFields()` | +| `Remove` | Mutates the receiver in place. Returns `bool` (whether something was removed), not a logger. | `log.RemoveTransport("id")` | `Child()` is the one exception to the prefix rule: it returns a new logger (conventional name in Go logging libraries; mirrors zerolog/slog). Treat it the same as `With*` and assign the result. @@ -171,6 +172,11 @@ log.Warn("retrying") // Or per-call only (override): log.WithContext(otherCtx).Info("override for this entry") + +// WithStdlibContext is an alias for WithContext on both receivers, +// provided for discoverability (it names context.Context explicitly). +log = log.WithStdlibContext(ctx) +log.WithStdlibContext(otherCtx).Info("override for this entry") ``` Surfaced to transports via `TransportParams.Ctx` and to plugin dispatch hooks via `params.Ctx`. The `loghttp` middleware binds `r.Context()` automatically. See [Go Context](/logging-api/go-context). @@ -330,7 +336,7 @@ log.Info("served") // {"level":"info","time":"...","msg":"served","source":{"function":"main.handler","file":"/app/main.go","line":42}} ``` -Off by default. Costs ~100 ns / one runtime.Caller per emission when on. The slog Handler forwards `slog.Record.PC` automatically (no capture cost on the slog path). +Off by default. Costs ~600 ns / +5 allocs per emission when on (see [Benchmarks](/benchmarks#caller-info-configsource)). The slog Handler forwards `slog.Record.PC` automatically (no capture cost on the slog path). ## slog Interop diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 5318a30..111897a 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -18,6 +18,7 @@ type Config struct { Plugins []Plugin // plugins to register at construction time Prefix string // surfaced to transports as TransportParams.Prefix Disabled bool // suppress all output (default: false) + Level LogLevel // initial level threshold (default: every level enabled) ErrorSerializer ErrorSerializer // customize error rendering ErrorFieldName string // key for serialized error (default: "err") CopyMsgOnOnlyError bool // copy err.Error() into the message in ErrorOnly @@ -45,6 +46,21 @@ type RoutingConfig struct { } ``` +## New vs Build + +`New` panics on misconfiguration (no transport, both `Transport` and `Transports` set). `Build` returns an `error` instead, with the same validation. `New` fits program-start setup where a bad config is a programmer error; `Build` fits config loaded at runtime (env vars, config files) where you want to handle failure explicitly: + +```go +log, err := loglayer.Build(loglayer.Config{ + Transport: structured.New(structured.Config{}), +}) +if err != nil { + return fmt.Errorf("configure logger: %w", err) +} +``` + +Both report `loglayer.ErrNoTransport` when no transport is configured (via `errors.Is` on the `Build` error). + ## Transports Set exactly one of `Transport` or `Transports`: @@ -131,6 +147,23 @@ log := loglayer.New(loglayer.Config{ You can flip it at runtime with `log.EnableLogging()` / `log.DisableLogging()`. See [Adjusting Log Levels](/logging-api/adjusting-log-levels). +## Level + +The initial level threshold, applied at construction exactly like [`SetLevel`](/logging-api/adjusting-log-levels). Any of `LogLevelTrace` (5), `LogLevelDebug` (10), `LogLevelInfo` (20), `LogLevelWarn` (30), `LogLevelError` (40), `LogLevelFatal` (50), or `LogLevelPanic` (60). See [Log Levels](/logging-api/basic-logging#log-levels) for the full list. Any level below the threshold is dropped. + +The zero value means "no override": every level is enabled (the default). Levels start at `LogLevelTrace`, so zero is unambiguous. + +```go +log := loglayer.New(loglayer.Config{ + Transport: structured.New(structured.Config{}), + Level: loglayer.LogLevelInfo, // trace + debug dropped from construction +}) +``` + +Composes with `Disabled`: `Disabled: true` suppresses everything even when `Level` is set. + +Set it before `New` for config-driven loggers (env var, config file); use `SetLevel` at runtime to toggle live. + ## ErrorSerializer A function that converts `error` to a `map[string]any`. The default returns `{"message": err.Error()}`. Override to capture stack traces, error chains, or library-specific fields. We recommend [`github.com/rotisserie/eris`](https://github.com/rotisserie/eris), its `ToJSON` function plugs in directly: @@ -214,8 +247,8 @@ log.WithMetadata(loglayer.Metadata{"userId": "1234"}). // { // "msg": "user action failed", // "context": {"service": "api"}, -// "metadata":{"userId": "1234"}, -// "error": {"message": "boom"} +// "error": {"message": "boom"}, +// "metadata":{"userId": "1234"} // } ``` @@ -237,6 +270,10 @@ log.Fatal("logged, but process keeps running") `loglayer.NewMock()` enables this automatically. See [Mocking](/logging-api/mocking) and [Fatal Exits the Process](/logging-api/basic-logging#fatal-exits-the-process). +::: warning Fatal in a long-running worker skips cleanup +In service code with deferred cleanup (auto-updater re-exec, graceful shutdown) or from worker goroutines, a bare `log.Fatal(...)` kills the process immediately without running `defer`s. Set `DisableFatalExit: true` at the root and use `Error` in workers (or call `log.Fatal` only from a coordinator that drains first). +::: + ## MuteFields / MuteMetadata Boolean flags that suppress fields or metadata from output. The data is still tracked on the logger, only the emit step skips it. Useful in development to cut log noise without removing the calls. diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 8e04d3a..3deca5a 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -58,6 +58,17 @@ func main() { The example above sets `FieldsKey` and `MetadataFieldName` to nest fields and metadata under their own keys. See [Configuration](/configuration) for every knob on `loglayer.Config`: error serialization, field/metadata placement, prefix, source capture, group routing, fatal-exit control, and more. +When the config comes from a runtime source (env vars, config file), use `loglayer.Build` to handle errors explicitly instead of `New`, which panics. See [New vs Build](/configuration#new-vs-build): + +```go +log, err := loglayer.Build(loglayer.Config{ + Transport: structured.New(structured.Config{}), +}) +if err != nil { + return fmt.Errorf("configure logger: %w", err) +} +``` + ::: tip Pretty terminal output For local development, the [Pretty Transport](/transports/pretty) gives you colorized, theme-aware output with three view modes. Much easier to scan than raw JSON or the basic [Console Transport](/transports/console). ::: @@ -73,7 +84,7 @@ log := loglayer.New(loglayer.Config{ }) log.WithError(fmt.Errorf("op failed: %w", io.EOF)).Error("oops") -// {"err":{"message":"op failed: EOF","causes":[{"message":"EOF"}]}} +// {"err":{"causes":[{"message":"EOF"}],"message":"op failed: EOF"}} ``` For stack traces, custom shapes, or other options, see [Error Handling](/logging-api/error-handling). diff --git a/docs/src/index.md b/docs/src/index.md index 93ca017..df4ed1e 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -71,8 +71,8 @@ func main() { "time": "2026-04-25T12:00:00Z", "msg": "user action failed", "context": { "service": "api" }, - "metadata": { "userId": "1234" }, - "err": { "message": "something went wrong" } + "err": { "message": "something went wrong" }, + "metadata": { "userId": "1234" } } ``` diff --git a/docs/src/integrations/loghttp.md b/docs/src/integrations/loghttp.md index 8e697d3..07fa449 100644 --- a/docs/src/integrations/loghttp.md +++ b/docs/src/integrations/loghttp.md @@ -48,8 +48,8 @@ func handler(w http.ResponseWriter, r *http.Request) { A request to `GET /users` produces: ```json -{"level":"info","time":"...","msg":"looking up user","context":{"requestId":"3f1a...","method":"GET","path":"/users"}} -{"level":"info","time":"...","msg":"request completed","context":{"requestId":"3f1a...","method":"GET","path":"/users"},"metadata":{"status":200,"durationMs":2,"bytes":42}} +{"level":"info","time":"...","msg":"looking up user","context":{"method":"GET","path":"/users","requestId":"3f1a..."}} +{"level":"info","time":"...","msg":"request completed","context":{"method":"GET","path":"/users","requestId":"3f1a..."},"metadata":{"bytes":42,"durationMs":2,"status":200}} ``` ## What the Middleware Does diff --git a/docs/src/integrations/sloghandler.md b/docs/src/integrations/sloghandler.md index fe6aa92..78b6100 100644 --- a/docs/src/integrations/sloghandler.md +++ b/docs/src/integrations/sloghandler.md @@ -42,7 +42,7 @@ log.AddPlugin(redact.New(redact.Config{Keys: []string{"password"}})) slog.SetDefault(slog.New(sloghandler.New(log))) slog.Info("user signed in", "userId", 42, "password", "hunter2") -// {"level":"info","time":"...","msg":"user signed in","context":{"userId":42,"password":"[REDACTED]"}} +// {"level":"info","time":"...","msg":"user signed in","context":{"password":"[REDACTED]","userId":42},"source":{"function":"main.main","file":"/app/main.go","line":11}} ``` The redact plugin runs even though the call site is `slog.Info(...)`. Same for `oteltrace`, `datadogtrace`, fan-out across multiple transports, group routing, and runtime level mutation. @@ -97,7 +97,7 @@ slog.Info("hi") // {"...","msg":"hi","context":{"service":"api"}} slog.Info("with-attr", "k", "v") -// {"...","msg":"with-attr","context":{"service":"api","k":"v"}} +// {"...","msg":"with-attr","context":{"k":"v","service":"api"}} ``` ## Mixing slog and Loglayer Call Sites @@ -119,7 +119,7 @@ Both paths run through the same plugin pipeline and the same transports. ## Error Attrs -`slog.Any("err", err)` arrives as a field with the original `error` value. The transport decides how to serialize it (default is whatever the configured `ErrorSerializer` does, otherwise the JSON encoder calls `Error()`). +`slog.Any("err", err)` arrives as a field holding the original `error` value. The transport serializes it as a plain value: the JSON encoder marshals the error's exported fields, so an error with none (like one from `errors.New`) renders as `{}`. The configured `ErrorSerializer` does not apply to fields; it only runs on the entry-level error. If you want loglayer's structured error treatment (`{"err": {"message": ...}}` via the configured `ErrorSerializer`), call `log.WithError(err).Info(...)` directly on the loglayer side rather than passing the error as a slog attr. diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 9557e92..d23f105 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -36,8 +36,8 @@ log. ```json { "msg": "user action failed", - "metadata": { "userId": "1234" }, - "err": { "message": "something went wrong" } + "err": { "message": "something went wrong" }, + "metadata": { "userId": "1234" } } ``` @@ -100,8 +100,8 @@ log. { "msg": "Request failed", "context": { "requestId": "abc-123" }, - "metadata": { "duration": 150 }, - "err": { "message": "timeout" } + "err": { "message": "timeout" }, + "metadata": { "duration": 150 } } ``` diff --git a/docs/src/logging-api/_partials/combining-example.md b/docs/src/logging-api/_partials/combining-example.md index 623594b..70132a4 100644 --- a/docs/src/logging-api/_partials/combining-example.md +++ b/docs/src/logging-api/_partials/combining-example.md @@ -10,7 +10,7 @@ log.WithMetadata(loglayer.Metadata{"duration_ms": 120}). { "msg": "request failed", "requestId": "abc", - "duration_ms": 120, - "err": { "message": "..." } + "err": { "message": "..." }, + "duration_ms": 120 } ``` diff --git a/docs/src/logging-api/basic-logging.md b/docs/src/logging-api/basic-logging.md index 175395e..7f02fb7 100644 --- a/docs/src/logging-api/basic-logging.md +++ b/docs/src/logging-api/basic-logging.md @@ -59,6 +59,10 @@ log.Fatal("logged but no exit") // entry written, process continues `loglayer.NewMock()` enables this automatically. See [Mocking](/logging-api/mocking). +::: warning Fatal skips deferred cleanup +`os.Exit` does not run `defer`s. In service code with deferred cleanup (auto-updater re-exec, graceful shutdown) or from worker goroutines, a `Fatal` call kills the process without cleanup. Set `DisableFatalExit: true` at the root and use `Error` in workers (or call `Fatal` only from a coordinator that drains first). +::: + ## Panic Panics the Goroutine `log.Panic(...)` dispatches the entry, then calls `panic()`. Unlike Fatal, the panic is recoverable: a `defer recover()` higher up the call stack can catch it and continue. Use Panic when you want a logged unrecoverable error that a caller (or framework, like `chi.Recoverer`) can intercept. diff --git a/docs/src/logging-api/error-handling.md b/docs/src/logging-api/error-handling.md index e6c9c81..3d15ebf 100644 --- a/docs/src/logging-api/error-handling.md +++ b/docs/src/logging-api/error-handling.md @@ -88,8 +88,8 @@ log.WithError(err).Error("db query failed") "root": { "message": "connection refused", "stack": [ - "main.queryDB:/app/db.go:42", - "main.main:/app/main.go:12" + "main.main:/app/main.go:12", + "main.queryDB:/app/db.go:42" ] } } @@ -109,10 +109,10 @@ log := loglayer.New(loglayer.Config{ }) log.WithError(fmt.Errorf("op failed: %w", io.EOF)).Error("oops") -// {"err":{"message":"op failed: EOF","causes":[{"message":"EOF"}]}} +// {"err":{"causes":[{"message":"EOF"}],"message":"op failed: EOF"}} log.WithError(errors.Join(errA, errB)).Error("combined") -// {"err":{"message":"errA\nerrB","causes":[{"message":"errA"},{"message":"errB"}]}} +// {"err":{"causes":[{"message":"errA"},{"message":"errB"}],"message":"errA\nerrB"}} ``` Behavior: @@ -185,7 +185,7 @@ The default is `"err"`. Errors compose with fields and metadata: ```go -log.WithFields(loglayer.Fields{"requestId": "abc"}) +log = log.WithFields(loglayer.Fields{"requestId": "abc"}) log.WithMetadata(loglayer.Metadata{"retry_count": 3}). WithError(err). diff --git a/docs/src/logging-api/go-context.md b/docs/src/logging-api/go-context.md index 4767f69..19f2feb 100644 --- a/docs/src/logging-api/go-context.md +++ b/docs/src/logging-api/go-context.md @@ -12,6 +12,13 @@ LogLayer can attach a `context.Context` to log entries. Transports and plugins t - **`(*LogLayer).WithContext(ctx)`** returns a derived logger with the context **bound** to every subsequent emission. This is the recommended pattern for per-request handlers. - **`(*LogBuilder).WithContext(ctx)`** attaches the context to a **single emission only**. Useful as an override on a logger that already has a different context bound. +`WithStdlibContext` is an alias for `WithContext` on both receivers, provided for discoverability: it names the stdlib `context.Context` type explicitly, so it shows up in searches for "context". `WithContext` remains canonical. + +```go +log := base.WithStdlibContext(ctx) // same as base.WithContext(ctx) +log.WithStdlibContext(otherCtx).Info("single emission override") +``` + ## Binding to a logger (recommended) ```go diff --git a/docs/src/logging-api/groups.md b/docs/src/logging-api/groups.md index 4e2706b..08a3cc5 100644 --- a/docs/src/logging-api/groups.md +++ b/docs/src/logging-api/groups.md @@ -126,7 +126,7 @@ log := loglayer.New(loglayer.Config{ Transports: []loglayer.Transport{ pretty.New(pretty.Config{BaseConfig: transport.BaseConfig{ID: "pretty"}}), structured.New(structured.Config{BaseConfig: transport.BaseConfig{ID: "structured-file"}, Writer: file}), - datadogtransport.New(datadogtransport.Config{ + datadog.New(datadog.Config{ BaseConfig: transport.BaseConfig{ID: "datadog"}, APIKey: os.Getenv("DD_API_KEY"), }), diff --git a/docs/src/logging-api/metadata.md b/docs/src/logging-api/metadata.md index a2fcfae..312f3c1 100644 --- a/docs/src/logging-api/metadata.md +++ b/docs/src/logging-api/metadata.md @@ -7,7 +7,7 @@ description: "Per-log structured data: maps, structs, or any value." Metadata attaches structured data to a single log entry. Unlike [fields](/logging-api/fields), it does not persist. Once the entry is emitted, the metadata is discarded. -`WithMetadata` accepts **any** value. The core logger does no conversion; the transport decides how to serialize. +`WithMetadata` accepts **any** value. The core logger does no conversion; the transport decides how to serialize. Two shapes dominate: map metadata flattens to root keys; struct metadata is JSON-roundtripped so its fields also merge at the root. When `MetadataFieldName` is set on the core config, the whole metadata value nests under that key instead (see [MetadataFieldName](/configuration#metadatafieldname)). ## Struct vs Map: pick the right shape @@ -129,6 +129,21 @@ log.MetadataOnly(loglayer.Metadata{"cpu": "90%"}, loglayer.MetadataOnlyOpts{LogL The default level is `Info`. Passing `nil` is a no-op. +### KV-only entries + +`MetadataOnly` is the KV-only idiom: entries with data but no message. The [Structured Transport](/transports/structured) emits them as JSON objects, and for the terminal renderers the [CLI Transport](/transports/cli) renders them as `key=value` pairs only when `Config.ShowFields` is set. + +```go +// structured: {"level":"info","time":"...","msg":"","status":"healthy","memory":"512MB"} +// console (always) / cli (with ShowFields): memory=512MB status=healthy +log.MetadataOnly(loglayer.Metadata{ + "status": "healthy", + "memory": "512MB", +}) +``` + +The same shape is available with persistent fields: `log.WithFields(...).Info("")` produces an entry with fields but no message. Prefer `MetadataOnly` for per-event data, `WithFields(...).Info("")` when the keys belong to the logger's persistent bag. + ## Muting Metadata Suppress metadata in output without removing the call sites. The toggle is `atomic.Bool` so concurrent reads are safe, but flipping mid-emission can interleave (some entries see pre-toggle, others post). Treat it as a setup-time admin toggle. diff --git a/docs/src/logging-api/mocking.md b/docs/src/logging-api/mocking.md index 2a7caa6..6075bc6 100644 --- a/docs/src/logging-api/mocking.md +++ b/docs/src/logging-api/mocking.md @@ -39,7 +39,7 @@ It also sets [`DisableFatalExit: true`](/configuration#disablefatalexit) so test ```go log := loglayer.NewMock() -log.WithFields(loglayer.Fields{"requestId": "abc"}) +log = log.WithFields(loglayer.Fields{"requestId": "abc"}) log.SetLevel(loglayer.LogLevelWarn) log.Info("dropped: below threshold AND silent") @@ -51,6 +51,10 @@ log.IsLevelEnabled(loglayer.LogLevelInfo) // false This is the right default for unit tests of business logic. +::: tip Need to assert on the rendered output? +`NewMock()` emits nothing, and there is no `NewMockWithWriter`. When the test's purpose is to assert what a transport *renders* (exact level prefixes, logfmt, JSON shape), construct a real logger with the [transports/testing](/transports/testing) capture transport or a `bytes.Buffer` writer on a renderer transport (see [Testing Transports](/transports/testing-transports)), and assert against the captured lines or buffer. +::: + ## 2. Capturing Mock: `transports/testing` Use this when the test's purpose is to verify *what* was logged. The `transports/testing` package provides a transport that captures every entry into an in-memory library, exposed as typed `LogLine` values. @@ -97,6 +101,7 @@ type LogLine struct { Data loglayer.Data // assembled fields + error map; nil when neither were set Metadata any // raw value passed to WithMetadata Ctx context.Context // per-call context attached via WithContext; nil if not set + Prefix string // value from WithPrefix / Config.Prefix; empty when unset } ``` diff --git a/docs/src/plugins/creating-plugins.md b/docs/src/plugins/creating-plugins.md index 5ca88ae..bc1619e 100644 --- a/docs/src/plugins/creating-plugins.md +++ b/docs/src/plugins/creating-plugins.md @@ -425,7 +425,7 @@ loglayer.NewMetadataHook("redact", func(metadata any) any { `Cloner` handles maps (string-keyed), structs (json-tag aware), slices, arrays, pointers, and interface values. It skips unexported fields. Caller's input is never mutated. -The [`plugins/redact`](/plugins/redact) plugin is built on `Cloner`; [its source](https://github.com/loglayer/loglayer-go/blob/main/plugins/redact/redact.go) is the canonical reference for this pattern. It's also the canonical example of a multi-hook plugin (implements both `MetadataHook` and `FieldsHook`). +The [`plugins/redact`](/plugins/redact) plugin is built on `Cloner`; [its source](https://github.com/loglayer/loglayer-go/blob/main/plugins/redact/redact.go) is the canonical reference for this pattern. It's also the canonical example of a multi-hook plugin (implements `MetadataHook`, `FieldsHook`, and `DataHook`). ### Recipe 3: normalize to a map first diff --git a/docs/src/plugins/redact.md b/docs/src/plugins/redact.md index 6b78ec1..9783aa9 100644 --- a/docs/src/plugins/redact.md +++ b/docs/src/plugins/redact.md @@ -136,7 +136,7 @@ The plugin implements three hooks: The first two scrub data the caller passes in. `OnBeforeDataOut` exists so a `Patterns`-style redactor also catches secrets that only surface in `err.Error()` (LogLayer places `WithError` errors into `Data` as `{"err": {"message": err.Error()}}`). Without the third hook a credit-card-shaped string baked into an error message would slip past redaction. ```go -log.WithError(errors.New("auth failed for card 4111111111111111")).Error("oops") +log.WithError(errors.New("4111111111111111")).Error("oops") // {"err":{"message":"[REDACTED]"}, ...} ``` diff --git a/docs/src/plugins/testing-plugins.md b/docs/src/plugins/testing-plugins.md index 3a02107..1472fab 100644 --- a/docs/src/plugins/testing-plugins.md +++ b/docs/src/plugins/testing-plugins.md @@ -29,7 +29,7 @@ func TestMyPlugin_AddsField(t *testing.T) { } ``` -`PopLine` returns the most recent entry and removes it; `Lines()` returns all captured. Both are `LogLine` structs with `LogLevel`, `Messages`, `Data`, `Metadata`, and `Err` fields. See [`transports/testing`](/transports/testing) for the full helper API. +`PopLine` returns the most recent entry and removes it; `Lines()` returns all captured. Both are `LogLine` structs with `Level`, `Messages`, `Data`, `Metadata`, `Ctx`, and `Prefix` fields. See [`transports/testing`](/transports/testing) for the full helper API. ## Verifying input-side hooks don't mutate input diff --git a/docs/src/public/llms-full.txt b/docs/src/public/llms-full.txt index 449deef..826204d 100644 --- a/docs/src/public/llms-full.txt +++ b/docs/src/public/llms-full.txt @@ -23,7 +23,7 @@ go get go.loglayer.dev/transports/blank/v2 # Cloud (managed log services) go get go.loglayer.dev/transports/axiom/v2 -go get go.loglayer.dev/transports/betterstack/v2 +go get go.loglayer.dev/transports/betterstack go get go.loglayer.dev/transports/datadog/v2 go get go.loglayer.dev/transports/gcplogging/v2 go get go.loglayer.dev/transports/sentry/v2 @@ -61,6 +61,8 @@ The full module list is in [`monorel.toml`](https://github.com/loglayer/loglayer package main import ( + "errors" + "go.loglayer.dev/v2" "go.loglayer.dev/transports/structured/v2" ) @@ -125,7 +127,7 @@ log.WithMetadata(loglayer.Metadata{"userId": "123", "action": "login"}).Info("us // loglayer.M is a shorter alias log.WithMetadata(loglayer.M{"durationMs": 42}).Info("served") -// Struct metadata nests under "metadata" by default +// Struct metadata: its fields merge at the root (JSON-roundtripped) type Event struct { OrderID string `json:"orderId"` Path string `json:"path"` @@ -341,6 +343,7 @@ log := loglayer.New(loglayer.Config{ Prefix: "[auth]", Disabled: false, + Level: loglayer.LogLevelInfo, // initial threshold; zero = every level enabled // Errors ErrorSerializer: loglayer.UnwrappingErrorSerializer, @@ -403,7 +406,7 @@ child = child.WithFields(loglayer.F{"handler": "users"}) child.Info("request received") // emits with service AND handler ``` -`Child()` shallow-copies fields, level state, transports, plugins, and group routing. Group config is shared by reference (runtime changes propagate); persistent group tags from `WithGroup` are copied. +`Child()` shallow-copies the fields map and clones the level state. Transports, plugins, and group routing are shared as immutable snapshots; the mutators (`AddTransport`, `AddPlugin`, `AddGroup`, ...) publish a new snapshot on the receiver only, so runtime changes on one logger never affect the other. ## Log Level Control @@ -554,7 +557,7 @@ log.GetGroups() ## Go context.Context -Distinct from "context" in TypeScript LogLayer (which is what Go calls "fields"). Go's `context.Context` carries trace IDs, deadlines, request-scoped values. Use `WithContext` to attach it; transports and plugins read it via `TransportParams.Ctx`. +Distinct from "context" in TypeScript LogLayer (which is what Go calls "fields"). Go's `context.Context` carries trace IDs, deadlines, request-scoped values. Use `WithContext` to attach it; transports and plugins read it via `TransportParams.Ctx`. `WithStdlibContext` is an alias for `WithContext` on both `*LogLayer` and `*LogBuilder`, provided for discoverability (`WithContext` remains canonical). ```go // Per-call attachment via *LogBuilder @@ -564,6 +567,10 @@ log.WithContext(ctx).Info("request received") reqLog := log.WithContext(ctx) reqLog.Info("step 1") reqLog.Info("step 2") + +// Alias (identical behavior) +reqLog = reqLog.WithStdlibContext(ctx) +reqLog.WithStdlibContext(context.WithValue(ctx, "scope", "subop")).Info("per-call override") ``` For HTTP handlers, the [loghttp middleware](https://go.loglayer.dev/integrations/loghttp) auto-binds `r.Context()` to a per-request logger. Plugins like `oteltrace` and `datadogtrace` read trace IDs from `params.Ctx`. @@ -647,7 +654,6 @@ structured.New(structured.Config{ Writer: os.Stdout, // DateFn: func() string { return time.Now().UTC().Format(time.RFC3339) }, // LevelFn: func(l loglayer.LogLevel) string { return l.String() }, - // Indent: false, // MessageField, DateField, LevelField: customize key names }) ``` @@ -663,13 +669,13 @@ pretty.New(pretty.Config{ Writer: os.Stdout, NoColor: false, ViewMode: pretty.ViewModeInline, // or ViewModeMessageOnly / ViewModeExpanded - Theme: pretty.MoonlightTheme, + Theme: pretty.Moonlight(), // or Sunlight() / Neon() / Nature() / Pastel() }) ``` ### CLI Transport -Tuned for command-line application output rather than diagnostic logging. Short cargo/eslint-style level prefixes (`warning:`, `error:`, `fatal:`), stdout for info/debug and stderr for warn+, TTY-detected ANSI color, no timestamps. Renders the `WithPrefix` value in dim grey, separate from the level color. Fields/metadata dropped by default; opt in via `Config.ShowFields`. +Tuned for command-line application output rather than diagnostic logging. Short cargo/eslint-style level prefixes (`warning:`, `error:`, `fatal:`), stdout for info/debug/trace and stderr for warn/error/fatal/panic, per-stream TTY-detected ANSI color (info/debug/trace follow stdout, warn/error/fatal/panic follow stderr, pinned at construction), no timestamps. Renders the `WithPrefix` value in dim grey, separate from the level color. Fields/metadata dropped by default; opt in via `Config.ShowFields`. `Config.MessageFn func(loglayer.TransportParams) string` takes over the entire output line: its return value replaces the message plus the logfmt/table body (the level prefix, its color, and the user `WithPrefix` value still apply); an empty return falls back to normal rendering. ```go import "go.loglayer.dev/transports/cli/v2" @@ -679,6 +685,7 @@ cli.New(cli.Config{ // Color: cli.ColorAuto, // ColorNever / ColorAlways // ShowFields: false, // append fields/metadata after the message // TableColumnOrder: []string{"package"}, // pin leading columns for slice-of-map metadata tables; rest sort lex + // MessageFn: func(p loglayer.TransportParams) string { ... }, // full-line takeover }) ``` @@ -1150,8 +1157,8 @@ require.Equal(t, "[REDACTED]", md["pw"]) ## Currently out of scope -- Lazy evaluation in fields/metadata (TS LogLayer has `lazy()`; not in Go yet) -- Async lazy values +- Lazy evaluation in per-call metadata (`loglayer.Lazy` resolves only in `Fields` / `RawLogEntry.Fields`, not in `Metadata`; see Lazy Evaluation above) +- Async lazy values (the sync form ships as `loglayer.Lazy`; the async TS form has no Go equivalent) - Mixins (TS has `Mixins`; the Go equivalent is plugin authorship) - Context Managers / Log Level Managers as separate concepts (Go uses `context.Context` + the three-tier level system directly) diff --git a/docs/src/public/llms.txt b/docs/src/public/llms.txt index 9b40535..fd899fe 100644 --- a/docs/src/public/llms.txt +++ b/docs/src/public/llms.txt @@ -63,7 +63,7 @@ log.WithMetadata(loglayer.Metadata{"userId": "123", "action": "login"}).Info("us // loglayer.M is a shorter alias for loglayer.Metadata log.WithMetadata(loglayer.M{"durationMs": 42}).Info("served") -// Struct metadata nests under the "metadata" key by default +// Struct metadata: its fields merge at the root (JSON-roundtripped) type Event struct { OrderID string `json:"orderId"` Path string `json:"path"` @@ -170,6 +170,7 @@ log := loglayer.New(loglayer.Config{ // Optional Prefix: "[auth]", // surfaced to transports as TransportParams.Prefix Disabled: false, // master on/off + Level: loglayer.LogLevelInfo, // initial threshold (zero = every level enabled) ErrorSerializer: loglayer.UnwrappingErrorSerializer, ErrorFieldName: "err", // default CopyMsgOnOnlyError: false, // ErrorOnly copies err.Error() as the message @@ -308,7 +309,7 @@ log.SetTransports(t1, t2) ## Go context.Context -Distinct from "context" in TS LogLayer: Go's `context.Context` carries trace IDs, deadlines, request-scoped values. Use `WithContext` to attach it; transports and plugins read it via `TransportParams.Ctx`. +Distinct from "context" in TS LogLayer: Go's `context.Context` carries trace IDs, deadlines, request-scoped values. Use `WithContext` to attach it; transports and plugins read it via `TransportParams.Ctx`. `WithStdlibContext` is an alias for `WithContext` on both `*LogLayer` and `*LogBuilder`, provided for discoverability (`WithContext` remains canonical). ```go // Per-call attachment @@ -359,7 +360,7 @@ lines := lib.Lines() // []lltest.LogLine; assert on Level, Messages, Data, Meta - `transports/console`: plain `fmt.Println`-style - `transports/structured`: one JSON object per entry (production) - `transports/pretty`: colorized terminal output (local dev) -- `transports/cli`: tuned for command-line apps (short level prefixes, stdout/stderr routing, TTY-detected color, no timestamps, table rendering for slice-of-map metadata with `Config.TableColumnOrder` to pin leading columns) +- `transports/cli`: tuned for command-line apps (short level prefixes, stdout/stderr routing, per-stream TTY-detected color: info/debug/trace follow stdout, warn/error/fatal/panic follow stderr, no timestamps, table rendering for slice-of-map metadata with `Config.TableColumnOrder` to pin leading columns, `Config.MessageFn` for full-line takeover) - `transports/testing`: in-memory capture for tests - `transports/blank`: user-supplied dispatch function diff --git a/docs/src/transports/axiom.md b/docs/src/transports/axiom.md index 2b9bc24..ed54d42 100644 --- a/docs/src/transports/axiom.md +++ b/docs/src/transports/axiom.md @@ -17,30 +17,27 @@ go get go.loglayer.dev/transports/axiom/v2 ## Authenticating -Axiom authenticates with an API token. You can provide this to the transport in two ways: +Axiom authenticates with an API token. The transport takes a caller-supplied `*axiom.Client` (required; `New` panics with `ErrClientRequired` when it is nil). You construct the client yourself, and the `axiom-go` SDK reads the token from the environment for you: -1. **Pass the client directly**: Construct `*axiom.Client` yourself and pass it to the transport. -2. **Use environment variables**: The transport reads `AXIOM_TOKEN` if no client is provided. +| Env var | Read by | Purpose | +|---------|---------|---------| +| `AXIOM_TOKEN` | `axiom-go` SDK | API token with ingest permission. | +| `AXIOM_ORG_ID` | `axiom-go` SDK | Organization ID (required for personal tokens). | -| Env var | Purpose | -|---------|---------| -| `AXIOM_TOKEN` | API token with ingest permission. Used when constructing the client. | -| `AXIOM_ORG_ID` | Organization ID (required for personal tokens). | -| `AXIOM_DATASET` | Dataset name or ID to ingest logs into. | +The dataset is set on the transport via `Config.DatasetName`, not an env var. ### Using environment variables ```go import ( - "github.com/axiomhq/axiom-go/axiom" + axiomgo "github.com/axiomhq/axiom-go/axiom" "go.loglayer.dev/v2" "go.loglayer.dev/transports/axiom/v2" ) -// Client picks up AXIOM_TOKEN from the environment -client, err := axiom.NewClient( - axiom.SetAPITokenConfig(os.Getenv("AXIOM_TOKEN")), -) +// The client picks up AXIOM_TOKEN (and AXIOM_ORG_ID for personal tokens) +// from the environment. +client, err := axiomgo.NewClient() if err != nil { panic(err) } @@ -57,16 +54,13 @@ log := loglayer.New(loglayer.Config{ ```go import ( - "context" - - "github.com/axiomhq/axiom-go/axiom" + axiomgo "github.com/axiomhq/axiom-go/axiom" "go.loglayer.dev/v2" "go.loglayer.dev/transports/axiom/v2" ) -ctx := context.Background() -client, err := axiom.NewClient( - axiom.SetAPITokenConfig("your-api-token"), +client, err := axiomgo.NewClient( + axiomgo.SetAPITokenConfig("your-api-token"), ) if err != nil { panic(err) @@ -80,7 +74,7 @@ log := loglayer.New(loglayer.Config{ }) log.Info("user signed in") -log.WithMetadata(map[string]any{"userId": 42}).Warn("retry exhausted") +log.WithMetadata(loglayer.Metadata{"userId": 42}).Warn("retry exhausted") ``` ## Config @@ -113,9 +107,9 @@ Each log entry is ingested as a JSON object: - Map metadata flattened at root, or any other metadata nested under `metadata` ```go -log.WithFields(map[string]any{"requestId": "abc"}). +log.WithFields(loglayer.Fields{"requestId": "abc"}). WithError(errors.New("timeout")). - WithMetadata(map[string]any{"durationMs": 42}). + WithMetadata(loglayer.Metadata{"durationMs": 42}). Info("served") ``` diff --git a/docs/src/transports/betterstack.md b/docs/src/transports/betterstack.md index 5c6d1b4..f823e3c 100644 --- a/docs/src/transports/betterstack.md +++ b/docs/src/transports/betterstack.md @@ -62,7 +62,7 @@ When testing against a mock endpoint or using a proxy, set `Config.URL` directly ```go tr := betterstack.New(betterstack.Config{ SourceToken: "fake-for-tests", - URL: "http://localhost:8080/logs", // test server URL + URL: "https://logs.internal.acme.com", // on-prem intake URL }) ``` diff --git a/docs/src/transports/charmlog.md b/docs/src/transports/charmlog.md index 3f34fcc..ecf0fff 100644 --- a/docs/src/transports/charmlog.md +++ b/docs/src/transports/charmlog.md @@ -38,7 +38,7 @@ log := loglayer.New(loglayer.Config{ }) log.Info("hello") -// 2026-04-25 12:00:00 INFO hello +// 2026/04/25 12:00:00 INFO hello ``` If you don't pass a `Logger`, the transport constructs one writing to `Writer` (default `os.Stderr`). @@ -80,7 +80,7 @@ type User struct { } log.WithMetadata(User{ID: 7, Name: "Alice"}).Info("user") -// INFO user metadata={ID:7 Name:Alice} +// INFO user metadata="{ID:7 Name:Alice}" ``` charmbracelet renders the struct via its default formatter. Exact output shape depends on whether you've configured `JSONFormatter`, `TextFormatter`, or the default colored output. @@ -109,6 +109,8 @@ cl := log.GetLoggerInstance("charmlog").(*clog.Logger) cl.SetLevel(clog.DebugLevel) ``` +(`"charmlog"` is whatever you set as `BaseConfig.ID`; defaults to an auto-generated ID when unset.) + ## Level Mapping | LogLayer Level | charmbracelet Level | Note | diff --git a/docs/src/transports/cli.md b/docs/src/transports/cli.md index a9d0c2c..940120d 100644 --- a/docs/src/transports/cli.md +++ b/docs/src/transports/cli.md @@ -11,7 +11,7 @@ The `cli` transport renders log entries as plain user-facing CLI output. The clo - **No timestamp, no log-id, no level label embedded in info / debug output.** The message is printed as-is. - **Short cargo / eslint-style prefixes for warn / error / fatal**: `warning: `, `error: `, `fatal: `. -- **Stdout for info / debug; stderr for warn / error / fatal / panic.** +- **Stdout for info / debug / trace; stderr for warn / error / fatal / panic.** - **TTY-detected color.** Pipe to a file or another process and ANSI escapes auto-disable. Override via `Config.Color`. - **Fields and metadata are dropped by default.** CLI users don't want `key=value` noise on user-facing output. Set `ShowFields: true` when wiring `-vv` / `--debug` to a verbose mode. - **Table rendering for slice metadata.** Pass `[]loglayer.Metadata`, `[]SomeStruct`, or any other slice of map-shaped or struct-shaped values to `WithMetadata` / `MetadataOnly` and the transport renders a tabwriter-aligned table after the message. Same call site emits a proper JSON array when paired with the [structured](/transports/structured) transport. See [Table Rendering for Slice-of-Map Metadata](#table-rendering-for-slice-of-map-metadata) below. @@ -57,7 +57,8 @@ Message strings have control bytes (including `\n`) stripped to defeat log-forgi |-------|------|---------|-------------| | `Stdout` | `io.Writer` | `os.Stdout` | Override for the info / debug / trace stream. | | `Stderr` | `io.Writer` | `os.Stderr` | Override for the warn / error / fatal / panic stream. | -| `Color` | `ColorMode` | `ColorAuto` | One of `ColorAuto` (color when stdout is a TTY), `ColorAlways`, or `ColorNever`. Wire your CLI's `--color` flag through this. | +| `Color` | `ColorMode` | `ColorAuto` | One of `ColorAuto` (per-stream TTY detection, see [below](#color-auto-always-never)), `ColorAlways`, or `ColorNever`. Wire your CLI's `--color` flag through this. | +| `MessageFn` | `func(loglayer.TransportParams) string` | `nil` | Format the entire output line (full takeover). The return value replaces the message plus the logfmt / table body; the level prefix and its color still apply. See [MessageFn](#messagefn) below. | | `ShowFields` | `bool` | `false` | When true, append `key=value` pairs (logfmt) after the message. Useful for `-vv` / `--debug` verbosity modes. | | `LevelPrefix` | `map[loglayer.LogLevel]string` | see below | Override the per-level prefix. Missing entries fall back to defaults. Set an entry to `""` to suppress the default prefix for that level only. | | `DisableLevelPrefix` | `bool` | `false` | Master switch: when true, every level's prefix is suppressed regardless of `LevelPrefix`. Use when the host CLI already renders its own urgency markers. | @@ -210,9 +211,51 @@ default: } ``` -`ColorAuto` checks whether the resolved stdout is a terminal at construction time, and that decision is pinned for the lifetime of the transport. If your CLI is invoked from a wrapper that pipes stdout, color disables automatically. +`ColorAuto` resolves the TTY status of each stream at construction time, and the decision is pinned for the lifetime of the transport: -Note that the TTY check is against `Stdout`, not `Stderr`: piping stdout to a file disables color on stderr-bound warn / error / fatal lines too. This matches how `gh`, `kubectl`, and most modern CLIs behave; the operator either wants color everywhere or nowhere, not a half-and-half mix. +- Info / debug / trace lines follow the resolved stdout (`Config.Stdout`, default `os.Stdout`). +- Warn / error / fatal / panic lines follow the resolved stderr (`Config.Stderr`, default `os.Stderr`). + +If your CLI is invoked from a wrapper that pipes stdout, severity lines stay colored as long as stderr is still a terminal (e.g. `cli ... | less`), instead of losing color because the stdout check failed. + +## MessageFn + +`Config.MessageFn` takes over the entire output line: its return value replaces the assembled message plus any logfmt or table body with a single user-controlled string. The level prefix and its color still apply to the result. + +Use it when the default `[level prefix][user prefix][message] [fields]` layout doesn't fit your CLI's established output format: + +```go +import ( + "fmt" + + "go.loglayer.dev/v2" + "go.loglayer.dev/v2/transport" + cli "go.loglayer.dev/transports/cli/v2" +) + +log := loglayer.New(loglayer.Config{ + Transport: cli.New(cli.Config{ + MessageFn: func(p loglayer.TransportParams) string { + return fmt.Sprintf("%v -> %v", p.LogLevel, transport.JoinMessages(p.Messages)) + }, + }), +}) +``` + +An empty return falls back to the normal rendering, so the hook can opt out per entry: + +```go +MessageFn: func(p loglayer.TransportParams) string { + if p.Metadata == nil { + return "" // normal rendering for plain entries + } + return renderRichLine(p) +}, +``` + +The return value is sanitized like any other rendered body, so a hostile message can't forge lines or smuggle terminal escapes. The user prefix (`WithPrefix`) still renders ahead of the body, in its usual dim-grey color; include `p.Prefix` in the format string if your format itself needs it. + +The contrast with the [Console Transport](/transports/console): console's `MessageFn` formats only the message, and the logfmt tail still appends. Here the body is replaced wholesale. ## Recommended Plugin Pairings diff --git a/docs/src/transports/configuration.md b/docs/src/transports/configuration.md index 83ed5be..c8c3124 100644 --- a/docs/src/transports/configuration.md +++ b/docs/src/transports/configuration.md @@ -53,13 +53,17 @@ console.New(console.Config{ `BaseConfig.ID` is optional. When you omit it, the transport gets an auto-generated ID, so multiple no-ID transports never collide. **Supply your own ID** when you'll later need to address that specific transport: `RemoveTransport(id)`, `GetLoggerInstance(id)`, and `AddTransport`'s replace-by-ID semantics all key off the string you set. +The base config lives in the shared `transport` package. Import it alongside the transport: + ```go +import "go.loglayer.dev/v2/transport" + console.New(console.Config{ BaseConfig: transport.BaseConfig{ID: "console"}, }) ``` -For transports you set up once and never touch (a single console renderer, a one-shot test transport), leaving `ID` empty is fine: the auto-generated ID still works for routing, you just won't have a stable handle for management calls. +An ID is only needed when the logger will manage that transport at runtime: `RemoveTransport(id)`, `GetLoggerInstance(id)`, and replace-by-ID. For transports you set up once and never touch (a single console renderer, a one-shot test transport), leaving `ID` empty is fine: the auto-generated ID still works for routing and group dispatch, you just won't have a stable handle for management calls. ## Enabling and disabling per environment diff --git a/docs/src/transports/console.md b/docs/src/transports/console.md index 6b63a33..8866765 100644 --- a/docs/src/transports/console.md +++ b/docs/src/transports/console.md @@ -155,7 +155,7 @@ If you only want JSON output, prefer the [structured transport](/transports/stru ```go console.New(console.Config{ MessageFn: func(p loglayer.TransportParams) string { - return fmt.Sprintf("[%s] %s", p.LogLevel, strings.Join(stringifyMessages(p.Messages), " | ")) + return fmt.Sprintf("[%s] %s", p.LogLevel, transport.JoinMessages(p.Messages)) }, }) ``` diff --git a/docs/src/transports/creating-transports.md b/docs/src/transports/creating-transports.md index 8901823..5512b55 100644 --- a/docs/src/transports/creating-transports.md +++ b/docs/src/transports/creating-transports.md @@ -308,7 +308,7 @@ Match the pattern the built-ins use ([`transports/structured`](https://github.co ## Testing -For testing a custom transport, see [Testing Transports](/transports/testing-transports). It covers the direct buffer assertion pattern and the `RunContract` helper that drives the same 14-test contract suite every built-in wrapper passes. +For testing a custom transport, see [Testing Transports](/transports/testing-transports). It covers the direct buffer assertion pattern and the `RunContract` helper that drives the same 17-test contract suite every built-in wrapper passes. ### Live Tests diff --git a/docs/src/transports/datadog.md b/docs/src/transports/datadog.md index 1b6d31b..6348194 100644 --- a/docs/src/transports/datadog.md +++ b/docs/src/transports/datadog.md @@ -41,10 +41,14 @@ The API key is a secret. Treat it like a password: load it from an environment v ```go import ( + "os" + "go.loglayer.dev/v2" "go.loglayer.dev/transports/datadog/v2" ) +hostname, _ := os.Hostname() + tr := datadog.New(datadog.Config{ APIKey: os.Getenv("DD_API_KEY"), Site: datadog.SiteUS1, // or SiteEU, SiteUS3, SiteUS5, SiteAP1 diff --git a/docs/src/transports/http.md b/docs/src/transports/http.md index 3ef58c5..d490474 100644 --- a/docs/src/transports/http.md +++ b/docs/src/transports/http.md @@ -19,6 +19,8 @@ The directory is `transports/http`; the package name is `httptransport` to avoid ```go import ( + "os" + "go.loglayer.dev/v2" httptr "go.loglayer.dev/transports/http/v2" ) @@ -26,7 +28,7 @@ import ( tr := httptr.New(httptr.Config{ URL: "https://logs.example.com/ingest", Headers: map[string]string{ - "Authorization": "Bearer " + token, + "Authorization": "Bearer " + os.Getenv("INGEST_TOKEN"), }, }) @@ -96,8 +98,10 @@ type Entry struct { Level loglayer.LogLevel Time time.Time Messages []any - Data map[string]any // fields + error (may be nil) - Metadata any // raw value passed to WithMetadata + Data map[string]any // fields + error (may be nil) + Metadata any // raw value passed to WithMetadata + Groups []string // active groups for this entry + Schema loglayer.Schema // resolved assembly-shape keys } ``` diff --git a/docs/src/transports/logrus.md b/docs/src/transports/logrus.md index 453a1d0..95252a9 100644 --- a/docs/src/transports/logrus.md +++ b/docs/src/transports/logrus.md @@ -111,6 +111,8 @@ l := log.GetLoggerInstance("logrus").(*logrus.Logger) l.AddHook(myHook) ``` +(`"logrus"` is whatever you set as `BaseConfig.ID`; defaults to an auto-generated ID when unset.) + ## Level Mapping | LogLayer Level | logrus Level | diff --git a/docs/src/transports/lumberjack.md b/docs/src/transports/lumberjack.md index d37c882..3151fb0 100644 --- a/docs/src/transports/lumberjack.md +++ b/docs/src/transports/lumberjack.md @@ -162,7 +162,6 @@ If you keep a reference to the transport, get it directly. The upstream library import ( lj "gopkg.in/natefinch/lumberjack.v2" - "go.loglayer.dev/v2" "go.loglayer.dev/transports/lumberjack/v2" ) @@ -257,9 +256,11 @@ errorLog := lumberjack.New(lumberjack.Config{ log := loglayer.New(loglayer.Config{ Transports: []loglayer.Transport{infoLog, errorLog}, }) + +log.Info("served") ``` -`info.log` ends up with everything at info level and above (including errors). `error.log` contains only the error-and-above subset. To omit info/warn from `info.log` without sending them anywhere, change the logger's own level instead via `loglayer.Config.MinLevel`. +`info.log` ends up with everything at info level and above (including errors). `error.log` contains only the error-and-above subset. To omit info/warn from `info.log` without sending them anywhere, change the logger's own level instead via `loglayer.Config.Level`. ### Daily / time-based rotation diff --git a/docs/src/transports/management.md b/docs/src/transports/management.md index a7d597d..6ef6151 100644 --- a/docs/src/transports/management.md +++ b/docs/src/transports/management.md @@ -14,6 +14,8 @@ For construction-time setup (wiring transports via `Config.Transport` / `Config. `AddTransport(transports...)` appends. If a transport with the same `ID` already exists it is **replaced**, not duplicated: ```go +import "go.loglayer.dev/v2/transport" + log.AddTransport(structured.New(structured.Config{ BaseConfig: transport.BaseConfig{ID: "ship"}, Writer: logFile, diff --git a/docs/src/transports/newrelic.md b/docs/src/transports/newrelic.md index f7ff492..6648ae9 100644 --- a/docs/src/transports/newrelic.md +++ b/docs/src/transports/newrelic.md @@ -40,6 +40,8 @@ The license key is a secret. Treat it like a password: load it from an environme ```go import ( + "os" + "go.loglayer.dev/v2" "go.loglayer.dev/transports/newrelic" ) diff --git a/docs/src/transports/otellog.md b/docs/src/transports/otellog.md index 8566506..edfc758 100644 --- a/docs/src/transports/otellog.md +++ b/docs/src/transports/otellog.md @@ -53,6 +53,8 @@ import ( "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + + "go.loglayer.dev/transports/otellog/v2" ) res := resource.NewSchemaless( @@ -219,4 +221,4 @@ import otellogapi "go.opentelemetry.io/otel/log" l := log.GetLoggerInstance("otellog").(otellogapi.Logger) ``` -(`"otellog"` is the default `BaseConfig.ID`; set `BaseConfig.ID` explicitly when running multiple OTel transports.) +(`"otellog"` is whatever you set as `BaseConfig.ID`; defaults to an auto-generated ID when unset, so assign it explicitly to look the transport up by ID.) diff --git a/docs/src/transports/phuslu.md b/docs/src/transports/phuslu.md index 1c60e3e..4b0e626 100644 --- a/docs/src/transports/phuslu.md +++ b/docs/src/transports/phuslu.md @@ -55,7 +55,7 @@ type Config struct { ## Fatal Behavior ::: danger phuslu always exits on Fatal -phuslu calls `os.Exit(1)` from every fatal-level dispatch path, including `Logger.WithLevel(FatalLevel).Msg(...)`. **This wrapper cannot suppress that behavior.** A `log.Fatal(...)` through the phuslu transport WILL terminate the process even when [`Config.DisableFatalExit`](/configuration#disablefatalexit) is set to `true`. +phuslu calls `os.Exit(255)` from every fatal-level dispatch path, including `Logger.WithLevel(FatalLevel).Msg(...)`. **This wrapper cannot suppress that behavior.** A `log.Fatal(...)` through the phuslu transport WILL terminate the process even when [`Config.DisableFatalExit`](/configuration#disablefatalexit) is set to `true`. If you need fatal paths to not exit (tests, library code, integration scenarios), use a different transport for those scenarios. The [structured](/transports/structured), [zerolog](/transports/zerolog), and [zap](/transports/zap) transports all honor `DisableFatalExit`. ::: @@ -70,7 +70,7 @@ For non-fatal levels, the wrapper dispatches via `Logger.WithLevel(level).Msg(.. ```go log.WithMetadata(loglayer.Metadata{"requestId": "xyz", "n": 42}).Info("served") -// {"time":"...","level":"info","message":"served","requestId":"xyz","n":42} +// {"time":"...","level":"info","requestId":"xyz","n":42,"message":"served"} ``` Each map entry becomes an `Entry.Any(k, v)` call. @@ -84,7 +84,7 @@ type User struct { } log.WithMetadata(User{ID: 7, Name: "Alice"}).Info("user") -// {"time":"...","level":"info","message":"user","metadata":{"id":7,"name":"Alice"}} +// {"time":"...","level":"info","metadata":{"id":7,"name":"Alice"},"message":"user"} ``` To use a different key per call, wrap in a map: @@ -111,6 +111,8 @@ p := log.GetLoggerInstance("phuslu").(*plog.Logger) p.SetLevel(plog.DebugLevel) ``` +(`"phuslu"` is whatever you set as `BaseConfig.ID`; defaults to an auto-generated ID when unset.) + ## Level Mapping | LogLayer Level | phuslu Level | diff --git a/docs/src/transports/pretty.md b/docs/src/transports/pretty.md index f64d81a..38e48a0 100644 --- a/docs/src/transports/pretty.md +++ b/docs/src/transports/pretty.md @@ -34,7 +34,7 @@ log.WithMetadata(loglayer.Metadata{"user": "alice", "n": 42}).Info("served") ``` ``` -12:34:56.789 ▶ INFO served metadata={n=42, user=alice} +12:34:56.789 ▶ INFO served metadata={n=42 user=alice} ``` (With colors applied by the default Moonlight theme.) @@ -56,7 +56,7 @@ pretty.New(pretty.Config{ViewMode: pretty.ViewModeInline}) ``` ``` -12:34:56.789 ▶ INFO served user=alice n=42 +12:34:56.789 ▶ INFO served n=42 user=alice ``` ### Message-only @@ -81,13 +81,13 @@ pretty.New(pretty.Config{ViewMode: pretty.ViewModeExpanded}) ``` 12:34:56.789 ▶ INFO served - user: alice - request: - method: POST - path: /users items: - first - second + request: + method: POST + path: /users + user: alice ``` ## Themes @@ -111,24 +111,34 @@ pretty.New(pretty.Config{Theme: pretty.Neon()}) A `*pretty.Theme` is just a struct of `Style` functions (`func(string) string`). Build one with `color.RGB(...)` or any other color library: ```go -import "github.com/fatih/color" +import ( + "github.com/fatih/color" + + "go.loglayer.dev/transports/pretty/v2" +) + +// Style is func(string) string; fatih/color's SprintFunc returns +// func(...any) string, so wrap each color in a one-argument closure. +style := func(c *color.Color) pretty.Style { + return func(s string) string { return c.Sprint(s) } +} theme := &pretty.Theme{ - Debug: color.New(color.FgCyan).SprintFunc(), - Info: color.New(color.FgGreen).SprintFunc(), - Warn: color.New(color.FgYellow).SprintFunc(), - Error: color.New(color.FgRed).SprintFunc(), - Fatal: color.New(color.BgRed, color.FgWhite).SprintFunc(), - Timestamp: color.New(color.Faint).SprintFunc(), - LogID: color.New(color.Faint).SprintFunc(), - DataKey: color.New(color.FgCyan).SprintFunc(), - DataValue: color.New(color.FgWhite).SprintFunc(), + Debug: style(color.New(color.FgCyan)), + Info: style(color.New(color.FgGreen)), + Warn: style(color.New(color.FgYellow)), + Error: style(color.New(color.FgRed)), + Fatal: style(color.New(color.BgRed, color.FgWhite)), + Timestamp: style(color.New(color.Faint)), + LogID: style(color.New(color.Faint)), + DataKey: style(color.New(color.FgCyan)), + DataValue: style(color.New(color.FgWhite)), } pretty.New(pretty.Config{Theme: theme}) ``` -(Note: `color.New(...).SprintFunc()` returns `func(...any) string`, but `Style` is `func(string) string`. Wrap in `func(s string) string { return c.Sprint(s) }` if needed.) +The `style` wrapper is required, not optional: `color.New(...).SprintFunc()` returns `func(...any) string`, but `Style` is `func(string) string`, so the two are not assignment-compatible. ## Config diff --git a/docs/src/transports/sentry.md b/docs/src/transports/sentry.md index 0e5ccca..970fb8e 100644 --- a/docs/src/transports/sentry.md +++ b/docs/src/transports/sentry.md @@ -35,6 +35,7 @@ The DSN is non-secret in the sense that it's safe to ship in client-side apps, b ```go import ( "context" + "time" "github.com/getsentry/sentry-go" diff --git a/docs/src/transports/slog.md b/docs/src/transports/slog.md index 35bf027..c8bd117 100644 --- a/docs/src/transports/slog.md +++ b/docs/src/transports/slog.md @@ -60,7 +60,7 @@ slog has no fatal level. This transport maps `LogLevelFatal` to `slog.LevelError ```go log.WithMetadata(loglayer.Metadata{"requestId": "abc", "n": 42}).Info("served") -// {"level":"INFO","msg":"served","requestId":"abc","n":42} +// {"time":"...","level":"INFO","msg":"served","requestId":"abc","n":42} ``` Each map entry becomes a `slog.Any(k, v)` attribute, so slog renders it via the configured handler (JSON, text, or anything custom). @@ -74,7 +74,7 @@ type User struct { } log.WithMetadata(User{ID: 7, Name: "Alice"}).Info("user") -// {"level":"INFO","msg":"user","metadata":{"id":7,"name":"Alice"}} +// {"time":"...","level":"INFO","msg":"user","metadata":{"id":7,"name":"Alice"}} ``` The JSON handler honors `json:` tags; other handlers may render fields differently. @@ -101,6 +101,8 @@ sl := log.GetLoggerInstance("slog").(*slog.Logger) sl.With("global", "field").Info("...") ``` +(`"slog"` is whatever you set as `BaseConfig.ID`; defaults to an auto-generated ID when unset.) + ## Level Mapping | LogLayer Level | slog Level | Note | diff --git a/docs/src/transports/testing-transports.md b/docs/src/transports/testing-transports.md index 4df8c5e..ef3148a 100644 --- a/docs/src/transports/testing-transports.md +++ b/docs/src/transports/testing-transports.md @@ -45,7 +45,7 @@ For wrapper transports (those that hand entries off to a third-party logger), as ## The wrapper contract suite -`transport/transporttest` ships a [`RunContract`](https://pkg.go.dev/go.loglayer.dev/v2/transport/transporttest#RunContract) helper that drives 14 sub-tests against any wrapper-shaped transport (renders to a buffer in JSON-per-line). The same suite verifies every built-in wrapper. Wire it in with a `Factory` closure that builds a fresh `(*loglayer.LogLayer, *bytes.Buffer)` honoring per-test config overrides, plus an `Expectations` struct describing your wrapper's rendering quirks (message key, level rendering, fatal handling): +`transport/transporttest` ships a [`RunContract`](https://pkg.go.dev/go.loglayer.dev/v2/transport/transporttest#RunContract) helper that drives 17 sub-tests against any wrapper-shaped transport (renders to a buffer in JSON-per-line). The same suite verifies every built-in wrapper. Wire it in with a `Factory` closure that builds a fresh `(*loglayer.LogLayer, *bytes.Buffer)` honoring per-test config overrides, plus an `Expectations` struct describing your wrapper's rendering quirks (message key, level rendering, fatal handling): ```go func factory(opts transporttest.FactoryOpts) (*loglayer.LogLayer, *bytes.Buffer) { diff --git a/docs/src/transports/testing.md b/docs/src/transports/testing.md index fb7b921..0aaf617 100644 --- a/docs/src/transports/testing.md +++ b/docs/src/transports/testing.md @@ -53,6 +53,7 @@ type LogLine struct { Data loglayer.Data // assembled fields + error map; nil when neither were set Metadata any // raw value passed to WithMetadata Ctx context.Context // per-call context attached via WithContext; nil if not set + Prefix string // value attached via WithPrefix; empty when none was set } ``` diff --git a/docs/src/transports/writers.md b/docs/src/transports/writers.md index d36a5bc..ee1f56a 100644 --- a/docs/src/transports/writers.md +++ b/docs/src/transports/writers.md @@ -21,7 +21,7 @@ log := loglayer.New(loglayer.Config{ |-----------|-------------| | `structured` | `os.Stdout` | | `pretty` | `os.Stdout` | -| `console` | `os.Stdout` for debug/info, `os.Stderr` for warn/error/fatal | +| `console` | `os.Stdout` for trace/debug/info, `os.Stderr` for warn/error/fatal/panic | | `testing` | In-memory; the Writer field is intentionally absent | ## Recipes diff --git a/docs/src/transports/zap.md b/docs/src/transports/zap.md index f5fd55c..f00a5ad 100644 --- a/docs/src/transports/zap.md +++ b/docs/src/transports/zap.md @@ -31,7 +31,7 @@ log := loglayer.New(loglayer.Config{ }) log.Info("hello") -// {"level":"info","ts":...,"msg":"hello"} +// {"level":"info","ts":...,"caller":"...","msg":"hello"} ``` If you don't pass a `Logger`, the transport constructs one with a JSON encoder writing to `Writer` (default `os.Stderr`). @@ -55,7 +55,7 @@ type Config struct { ```go log.WithMetadata(loglayer.Metadata{"requestId": "abc", "n": 42}).Info("served") -// {"level":"info","msg":"served","requestId":"abc","n":42} +// {"level":"info","ts":...,"caller":"...","msg":"served","requestId":"abc","n":42} ``` Each map entry becomes a `zap.Any(k, v)` call, so zap renders it however its encoder is configured. @@ -69,7 +69,7 @@ type User struct { } log.WithMetadata(User{ID: 7, Name: "Alice"}).Info("user") -// {"level":"info","msg":"user","metadata":{"id":7,"name":"Alice"}} +// {"level":"info","ts":...,"caller":"...","msg":"user","metadata":{"id":7,"name":"Alice"}} ``` zap reflects into the struct via `zap.Any`, which is faster than a JSON roundtrip. @@ -108,6 +108,8 @@ z := log.GetLoggerInstance("zap").(*zap.Logger) z.Sync() ``` +(`"zap"` is whatever you set as `BaseConfig.ID`; defaults to an auto-generated ID when unset.) + This is the wrapped instance, not the original you passed in. For most operations that doesn't matter: fields, sampling, and hooks set before passing the logger to LogLayer are preserved. ## Level Mapping diff --git a/docs/src/transports/zerolog.md b/docs/src/transports/zerolog.md index dfddff4..49ee1c9 100644 --- a/docs/src/transports/zerolog.md +++ b/docs/src/transports/zerolog.md @@ -56,7 +56,7 @@ type Config struct { ```go log.WithMetadata(loglayer.Metadata{"requestId": "abc", "n": 42}).Info("served") -// {"level":"info","time":"...","message":"served","requestId":"abc","n":42} +// {"level":"info","n":42,"requestId":"abc","time":"...","message":"served"} ``` ### Struct metadata nests under the metadata key @@ -68,7 +68,7 @@ type User struct { } log.WithMetadata(User{ID: 7, Name: "Alice"}).Info("user") -// {"level":"info","time":"...","message":"user","metadata":{"id":7,"name":"Alice"}} +// {"level":"info","metadata":{"id":7,"name":"Alice"},"time":"...","message":"user"} ``` Zerolog's `Interface` field handler reflects directly into the struct, so the value is encoded once at write time without an extra JSON roundtrip. @@ -95,7 +95,7 @@ Map fields are merged at the root via zerolog's `Fields`: ```go log.WithFields(loglayer.Fields{"service": "api"}) log.Info("request") -// {"level":"info","message":"request","service":"api",...} +// {"level":"info","service":"api","time":"...","message":"request"} ``` If `FieldsKey` is set on the LogLayer config, the fields are nested first by the core, then merged at root by zerolog. The result appears as a single nested object: @@ -108,7 +108,7 @@ loglayer.New(loglayer.Config{ log.WithFields(loglayer.Fields{"requestId": "abc"}) log.Info("hi") -// {"level":"info","message":"hi","fields":{"requestId":"abc"}} +// {"level":"info","fields":{"requestId":"abc"},"time":"...","message":"hi"} ``` ## Fatal Behavior @@ -136,6 +136,8 @@ z := log.GetLoggerInstance("zerolog").(*zlog.Logger) z.Hook(myHook) ``` +(`"zerolog"` is whatever you set as `BaseConfig.ID`; defaults to an auto-generated ID when unset.) + ## Level Mapping | LogLayer Level | zerolog Level | diff --git a/docs/src/whats-new.md b/docs/src/whats-new.md index 8a5f68b..2034ead 100644 --- a/docs/src/whats-new.md +++ b/docs/src/whats-new.md @@ -7,6 +7,22 @@ description: Latest features and improvements in LogLayer for Go. - See the [main `CHANGELOG.md`](https://github.com/loglayer/loglayer-go/blob/main/CHANGELOG.md) for the auto-generated per-release log. +## Aug 19, 2026 + +`loglayer`: + +- **`Config.Level` initial threshold**: set the minimum level at construction, applied exactly like `SetLevel`. Zero means "no override": every level stays enabled (the previous behavior). Composes with `Disabled`. See [Level](/configuration#level). +- **`WithStdlibContext` alias**: `WithContext` is now also reachable as `WithStdlibContext` on both `*LogLayer` and `*LogBuilder`, for discoverability when searching for "context". `WithContext` remains canonical. See [Go Context](/logging-api/go-context). + +`transports/cli`: + +- **Per-stream TTY detection in `ColorAuto`**: info / debug / trace lines follow stdout's TTY status; warn / error / fatal / panic lines follow stderr's. Piping stdout (e.g. `cli ... | less`) no longer strips color from severity lines that are still attached to a terminal. Resolution is pinned at construction. The counter-direction also holds: a real `*os.File` stderr plus a non-TTY stdout now renders warn/error uncolored. See [CLI Transport](/transports/cli#color-auto-always-never). +- **`Config.MessageFn` full-line takeover**: a callback that replaces the message plus the logfmt / table body with a single user-controlled string. The level prefix, its color, and the user prefix still apply; an empty return falls back to normal rendering. See [MessageFn](/transports/cli#messagefn). + +`transports/gcplogging`: + +Bumped `google.golang.org/grpc` (v1.79.3 → v1.82.1) to fix GO-2026-6061 (xDS RBAC authorization + HTTP/2 server vulnerabilities), reachable from the transport. Also raised transitive `golang.org/x/*`, `google.golang.org/genproto`, OpenTelemetry, and protobuf versions. + ## May 11, 2026 `transports/newrelic`: diff --git a/lefthook.yml b/lefthook.yml index 2544b15..85a6732 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -83,3 +83,14 @@ pre-push: # other op) directly when iterating. test-race: run: scripts/foreach-module.sh test + # Vulnerability scan before the push reaches CI. Fails on + # reachable DEPENDENCY findings (the repo's responsibility to bump), + # e.g. the grpc v1.79.3 -> v1.82.1 fix for GO-2026-6061. Stdlib + # findings are toolchain-env (only a Go upgrade fixes them; see + # AGENTS.md) and are reported as advisory, not gating. + # + # Missing govulncheck is a hard fail, matching staticcheck's + # convention: the gate only works if the tool actually runs. + # Bypass a single push with --no-verify if you genuinely need to. + vuln: + run: bash scripts/govulncheck-gate.sh diff --git a/levels_test.go b/levels_test.go index 67f43f1..2f049bb 100644 --- a/levels_test.go +++ b/levels_test.go @@ -215,3 +215,62 @@ func TestLogLevelString_TraceAndPanic(t *testing.T) { t.Errorf("Panic.String() = %q, want \"panic\"", loglayer.LogLevelPanic.String()) } } + +// Config.Level applies the threshold at construction, so there is no window +// where the logger is live at the wrong level before SetLevel runs. +func TestConfigLevelThreshold(t *testing.T) { + log, lib := setupWithConfig(t, loglayer.Config{Level: loglayer.LogLevelWarn}) + + log.Trace("dropped") + log.Debug("dropped") + log.Info("dropped") + if lib.Len() != 0 { + t.Errorf("expected no lines below warn, got %d", lib.Len()) + } + + log.Warn("kept") + log.Error("kept") + if lib.Len() != 2 { + t.Errorf("expected 2 lines at/above warn, got %d", lib.Len()) + } +} + +// Config.Level zero value means "no override": every level stays enabled. +func TestConfigLevelZeroKeepsEverythingEnabled(t *testing.T) { + log, lib := setupWithConfig(t, loglayer.Config{}) + + log.Trace("kept") + log.Debug("kept") + log.Info("kept") + if lib.Len() != 3 { + t.Errorf("expected all levels enabled by default, got %d lines", lib.Len()) + } + if !log.IsLevelEnabled(loglayer.LogLevelTrace) { + t.Error("trace should be enabled by default") + } +} + +// Config.Level composes with Config.Disabled: the master switch still +// suppresses everything even when the threshold would pass. +func TestConfigLevelWithDisabled(t *testing.T) { + log, lib := setupWithConfig(t, loglayer.Config{ + Level: loglayer.LogLevelInfo, + Disabled: true, + }) + + log.Info("suppressed by master switch") + log.Error("suppressed by master switch") + if lib.Len() != 0 { + t.Errorf("expected no lines with Disabled=true, got %d", lib.Len()) + } +} + +// Unknown levels are no-ops at construction, matching SetLevel's contract. +func TestConfigLevelUnknownLevelNoOp(t *testing.T) { + log, lib := setupWithConfig(t, loglayer.Config{Level: loglayer.LogLevel(123)}) + + log.Trace("still enabled") + if lib.Len() != 1 { + t.Errorf("unknown Config.Level should not change level state, got %d lines", lib.Len()) + } +} diff --git a/log.go b/log.go index 6b03222..5e7de67 100644 --- a/log.go +++ b/log.go @@ -29,6 +29,18 @@ func (l *LogLayer) WithContext(ctx context.Context) *LogLayer { return child } +// WithStdlibContext is an alias for WithContext that spells out what the +// argument is. loglayer.Fields holds the persistent key/value bag; stdlib +// context.Context is a different concept that carries trace IDs, deadlines, +// and request-scoped values. The alias exists so call sites where the two +// live next to each other read unambiguously. WithContext remains canonical. +// +// Same semantics as WithContext: returns a derived logger; assign the result; +// nil clears any bound context. +func (l *LogLayer) WithStdlibContext(ctx context.Context) *LogLayer { + return l.WithContext(ctx) +} + // Trace logs at the trace level. Trace sits below Debug; use it for // extremely fine-grained diagnostic output that you'd typically want // disabled in production. diff --git a/loglayer.go b/loglayer.go index c60ebd6..20ad366 100644 --- a/loglayer.go +++ b/loglayer.go @@ -195,6 +195,10 @@ func build(config Config) (*LogLayer, error) { l.config.Source.FieldName = "source" } + if config.Level != 0 { + l.levels.setLevel(config.Level) + } + if config.Disabled { l.levels.setMaster(false) } diff --git a/scripts/govulncheck-gate.sh b/scripts/govulncheck-gate.sh new file mode 100644 index 0000000..b42c87d --- /dev/null +++ b/scripts/govulncheck-gate.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Pre-push / CI gate for vulnerabilities reachable from this repo's code. +# +# Policy: fail only on findings the repo can fix. govulncheck reports +# three classes: +# - Standard library: fixed by upgrading the operating Go toolchain +# (e.g. crypto/tls@go1.26 -> go1.26.6). The repo cannot fix these; +# only the operator's `go` upgrade can. AGENTS.md documents this as +# the operator's responsibility. +# - Module / import (reachable): a dependency version we pin. This is +# the repo's responsibility: bump the dependency (like the grpc +# v1.79.3 -> v1.82.1 fix for GO-2026-6061 in transports/gcplogging). +# - Module / import (unreachable): "doesn't appear to call" - not +# reachable from code; accepted risk (same class as the advisory +# SessionStart hook's noise floor). +# +# Exit codes: +# 0 nothing reachable-and-fixable found +# 1 govulncheck missing +# 2 a reachable NON-stdlib (dependency) vulnerability found +# +# Usage: bash scripts/govulncheck-gate.sh +# Env: GOVULNCHECK_BIN override the govulncheck binary path +set -uo pipefail + +BIN="${GOVULNCHECK_BIN:-$(command -v govulncheck || true)}" +if [ -z "$BIN" ]; then + echo "govulncheck not on PATH. Install with: go install golang.org/x/vuln/cmd/govulncheck@latest" >&2 + exit 1 +fi + +# Same module list CI uses (shipped modules + the livetest module). +MODULES=(. transports/otellog plugins/oteltrace plugins/datadogtrace/livetest \ + transports/blank transports/betterstack transports/charmlog transports/cli \ + transports/console transports/datadog transports/gcplogging transports/http \ + transports/logrus transports/lumberjack transports/newrelic \ + transports/phuslu transports/pretty transports/sentry transports/slog \ + transports/structured transports/testing transports/zap transports/zerolog \ + integrations/loghttp integrations/sloghandler plugins/fmtlog plugins/redact \ + plugins/sampling plugins/plugintest transports/central) + +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT + +failures=0 +for mod in "${MODULES[@]}"; do + if ! (cd "$mod" && "$BIN" -scan=symbol ./...) >"$tmp" 2>&1; then + # Parse reachable findings. A finding is fixable when its "Found in" + # names a module other than the Go standard library (stdlib findings + # read `Found in: @go`). + fixable="$(awk ' + /^Vulnerability #/ { in_fixable = 0 } + /Found in:/ { + if ($0 !~ /@go[0-9]+\./) { in_fixable = 1 } + } + in_fixable && /^Vulnerability #/ { print $0 } + ' "$tmp")" + + if [ -n "$fixable" ]; then + echo "==> $mod (REACHABLE DEPENDENCY VULNERABILITY)" + grep -E "Vulnerability #|Found in:|Fixed in:|More info:" "$tmp" | head -30 + failures=1 + else + # Stdlib-only (or unreachable): advisory. Print a compact note so + # the operator can see the toolchain upgrade path, but don't gate. + stdlib="$(grep -cE '^Vulnerability #[0-9]+' "$tmp" || true)" + if [ "$stdlib" -gt 0 ]; then + echo "==> $mod (advisory: $stdlib stdlib/unreachable finding(s); upgrade Go toolchain to clear)" + fi + fi + fi +done + +if [ "$failures" -eq 1 ]; then + echo + echo "Reachable dependency vulnerabilities found. Bump the affected dependency" + echo "in the module's go.mod (see Fixed in: lines above), or skip this push" + echo "with --no-verify only if the finding is a false positive." + exit 2 +fi +exit 0 diff --git a/transports/cli/cli.go b/transports/cli/cli.go index 08c7f1b..7ace7da 100644 --- a/transports/cli/cli.go +++ b/transports/cli/cli.go @@ -11,9 +11,10 @@ // unambiguous when a CLI run mixes levels. // - Info / debug write to stdout; warn / error / fatal write to // stderr, matching long-standing CLI convention. -// - ANSI color is gated by TTY detection on stdout. Pipe to a file -// and the color disappears automatically. Override via -// [Config.Color]. +// - ANSI color is gated by per-stream TTY detection: info / debug +// follow stdout, warn / error / fatal follow stderr. Pipe only +// stdout (e.g. `cli ... | less`) and severity lines stay colored. +// Override via [Config.Color]. // - Fields and metadata are dropped by default. CLI users don't // want `key=value` noise on user-facing output. Set // [Config.ShowFields] to append them when running with `-vv` / @@ -90,6 +91,23 @@ type Config struct { // Color controls ANSI color output. Zero value is [ColorAuto]. Color ColorMode + // MessageFn, when set, formats the entire output line. Its return + // value replaces the message, the logfmt tail, and the table body + // with a single user-controlled string. The level prefix (and its + // color) still applies, so the line keeps its urgency marker. + // + // An empty return falls back to the normal rendering for that + // entry, which lets a caller opt out conditionally. + // + // This is the full-takeover escape hatch the console transport + // doesn't offer: there, the logfmt tail still appends after the + // MessageFn return value. + // + // The return value goes through the same sanitization as messages + // (ANSI / CRLF / bidi stripping), so a user-controlled format + // string can't smuggle terminal escapes into the output. + MessageFn func(params loglayer.TransportParams) string + // ShowFields, when true, appends fields and metadata after the // message in `key=value` form (logfmt). Default false: CLI // users don't want structured noise on user-facing output. @@ -161,14 +179,21 @@ type Transport struct { transport.BaseTransport cfg Config useANSI bool + useANSISeverity bool prefix map[loglayer.LogLevel]string colors map[loglayer.LogLevel]*color.Color userPrefixColor *color.Color } // New constructs a Transport from cfg. The TTY detection for -// [ColorAuto] runs once here against cfg.Stdout (or os.Stdout when -// cfg.Stdout is nil); subsequent writes don't re-check. +// [ColorAuto] runs once here, per stream: info / debug / trace +// follow cfg.Stdout (or os.Stdout), warn / error / fatal / panic +// follow cfg.Stderr (or os.Stderr). Subsequent writes don't +// re-check. This keeps severity lines colored when stdout is piped +// but stderr is still a terminal (e.g. `hmn ... | less`). +// +// ColorAlways and ColorNever override both streams; there is no +// per-stream color mode. func New(cfg Config) *Transport { t := &Transport{ BaseTransport: transport.NewBaseTransport(cfg.BaseConfig), @@ -185,7 +210,8 @@ func New(cfg Config) *Transport { t.prefix[level] = sanitize.Message(p) } maps.Copy(t.colors, cfg.LevelColor) - t.useANSI = resolveColor(cfg) + t.useANSI = resolveColor(cfg, cfg.Stdout, false) + t.useANSISeverity = resolveColor(cfg, cfg.Stderr, true) // fatih/color has a process-global `color.NoColor` flag that // the package auto-sets based on stdout TTY detection at @@ -198,12 +224,16 @@ func New(cfg Config) *Transport { // have passed us a color shared with another Transport, and // EnableColor / DisableColor mutate per-instance state on the // pointer. Copying decouples the two transports' resolutions. + // + // Each level's color is toggled by the stream that level writes + // to (severity levels go to stderr), so the two streams can + // carry different resolutions under ColorAuto. for level, c := range t.colors { if c == nil { continue } cp := *c - if t.useANSI { + if t.colorOn(level) { cp.EnableColor() } else { cp.DisableColor() @@ -212,9 +242,10 @@ func New(cfg Config) *Transport { } // Same shallow-copy + per-instance flag dance for the user- // prefix color so a transport with ColorAlways doesn't share - // the global NoColor with another transport. + // the global NoColor with another transport. The user prefix + // rides on the headline, which uses the level's stream color. upc := *t.userPrefixColor - if t.useANSI { + if t.useANSI || t.useANSISeverity { upc.EnableColor() } else { upc.DisableColor() @@ -223,6 +254,18 @@ func New(cfg Config) *Transport { return t } +// colorOn reports whether the level's stream resolves to ANSI under +// the configured Color mode. Severity levels (warn / error / fatal / +// panic) write to stderr; the rest write to stdout. +func (t *Transport) colorOn(level loglayer.LogLevel) bool { + switch level { + case loglayer.LogLevelWarn, loglayer.LogLevelError, loglayer.LogLevelFatal, loglayer.LogLevelPanic: + return t.useANSISeverity + default: + return t.useANSI + } +} + // GetLoggerInstance returns nil; the cli transport has no underlying // logger library. func (t *Transport) GetLoggerInstance() any { return nil } @@ -253,22 +296,17 @@ func (t *Transport) SendToLogger(params loglayer.TransportParams) { // color so it reads as caller-context rather than urgency. Tables // render neutral. func (t *Transport) format(params loglayer.TransportParams) string { - msg := transport.AssembleMessage(params.Messages, sanitize.Message) - - levelPrefix := "" - if !t.cfg.DisableLevelPrefix { - levelPrefix = t.prefix[params.LogLevel] + // MessageFn takes over the entire line. The level prefix and its + // color still apply; an empty return falls back to the normal + // rendering so the hook can opt out per entry. + if t.cfg.MessageFn != nil { + fnBody := sanitize.Message(t.cfg.MessageFn(params)) + if fnBody != "" { + return t.renderHeadline(params, fnBody) + } } - userPrefix := "" - if params.Prefix != "" { - // Sanitize the prefix in-line so a Config.Prefix / - // WithPrefix value loaded from env or config can't - // smuggle ANSI / CRLF through cli's smart-rendering - // path. Mirrors the sanitize call applied to messages, - // logfmt values, table cells, and LevelPrefix. - userPrefix = sanitize.Message(params.Prefix) + " " - } + msg := transport.AssembleMessage(params.Messages, sanitize.Message) // Append optional logfmt or capture a table. body := msg @@ -288,8 +326,35 @@ func (t *Transport) format(params loglayer.TransportParams) string { // Compose the headline. Level color tints the level prefix and // the message body together; the user prefix gets dim-grey. + headline := t.renderHeadline(params, body) + + switch { + case table == "": + return headline + case headline == "": + // MetadataOnly with table-shaped metadata: emit the table + // alone, no leading blank line. + return table + default: + return headline + "\n" + table + } +} + +// renderHeadline composes the level prefix, the user prefix, and the +// body into a single line, applying the level's stream color decision. +func (t *Transport) renderHeadline(params loglayer.TransportParams, body string) string { + levelPrefix := "" + if !t.cfg.DisableLevelPrefix { + levelPrefix = t.prefix[params.LogLevel] + } + + userPrefix := "" + if params.Prefix != "" { + userPrefix = sanitize.Message(params.Prefix) + " " + } + var levelPart, userPart, bodyPart string - if t.useANSI { + if t.colorOn(params.LogLevel) { if c, ok := t.colors[params.LogLevel]; ok && c != nil { levelPart = c.Sprint(levelPrefix) bodyPart = c.Sprint(body) @@ -305,18 +370,7 @@ func (t *Transport) format(params loglayer.TransportParams) string { userPart = userPrefix bodyPart = body } - headline := levelPart + userPart + bodyPart - - switch { - case table == "": - return headline - case headline == "": - // MetadataOnly with table-shaped metadata: emit the table - // alone, no leading blank line. - return table - default: - return headline + "\n" + table - } + return levelPart + userPart + bodyPart } // writer picks stdout vs stderr by level. @@ -336,20 +390,29 @@ func (t *Transport) writer(level loglayer.LogLevel) io.Writer { } // resolveColor returns the static ANSI on/off decision for cfg's -// configured Color mode. ColorAuto checks whether the resolved -// stdout is a TTY at construction time. -func resolveColor(cfg Config) bool { +// configured Color mode, against the given stream (cfg.Stdout for +// the info / debug / trace levels, cfg.Stderr for the severity +// levels). ColorAuto checks whether that stream is a TTY at +// construction time, so each stream's decision is pinned per stream. +// +// A nil stream means "the real default for that stream", which is +// what writer() would use: os.Stdout for the severity=false side, +// os.Stderr for the severity side. +func resolveColor(cfg Config, stream io.Writer, severity bool) bool { switch cfg.Color { case ColorAlways: return true case ColorNever: return false } - out := cfg.Stdout - if out == nil { - out = os.Stdout + if stream == nil { + if severity { + stream = os.Stderr + } else { + stream = os.Stdout + } } - if f, ok := out.(*os.File); ok { + if f, ok := stream.(*os.File); ok { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } return false diff --git a/transports/cli/cli_test.go b/transports/cli/cli_test.go index 694ab07..660fd0f 100644 --- a/transports/cli/cli_test.go +++ b/transports/cli/cli_test.go @@ -2,15 +2,73 @@ package cli_test import ( "bytes" + "fmt" + "os" + "runtime" "strings" "testing" + "time" "github.com/fatih/color" + "golang.org/x/sys/unix" clitr "go.loglayer.dev/transports/cli/v2" "go.loglayer.dev/v2" ) +// openPTY opens a new pseudo-terminal pair and returns the slave end (a +// real terminal fd, so isatty reports true) and the master end (to read +// output written to the slave). Cleanup closes both. Used to exercise +// ColorAuto's per-stream TTY check without a controlling terminal. +// Linux-only: the TIOCSPTLCK / TIOCGPTN ioctls are Linux-specific. +func openPTY(t *testing.T) (slave, master *os.File, cleanup func()) { + t.Helper() + if runtime.GOOS != "linux" { + t.Skipf("PTY ioctls are Linux-specific; skipping on %s", runtime.GOOS) + } + master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0) + if err != nil { + t.Skipf("cannot open /dev/ptmx: %v", err) + } + // Unlock the slave side (Linux convention) so it can be opened. + // TIOCSPTLCK takes a pointer to the int, not the value itself. + if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil { + master.Close() + t.Fatalf("TIOCSPTLCK unlock: %v", err) + } + n, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN) + if err != nil { + master.Close() + t.Fatalf("TIOCGPTN: %v", err) + } + slave, err = os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|unix.O_NOCTTY, 0) + if err != nil { + master.Close() + t.Fatalf("open slave /dev/pts/%d: %v", n, err) + } + cleanup = func() { + slave.Close() + master.Close() + } + return slave, master, cleanup +} + +// readTimeout reads up to len(buf) bytes from the PTY master with a +// deadline, so a missing write fails the test instead of hanging it. +func readTimeout(t *testing.T, f *os.File, buf []byte) string { + t.Helper() + if err := f.SetReadDeadline(nowAdd2s()); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + n, err := f.Read(buf) + if err != nil { + t.Fatalf("read from pty master: %v", err) + } + return string(buf[:n]) +} + +func nowAdd2s() (t_ time.Time) { return time.Now().Add(2 * time.Second) } + // makeLogger constructs a logger backed by a cli.Transport whose // stdout / stderr are captured into the returned buffers. Color is // forced off so assertions can match plain text. @@ -174,6 +232,211 @@ func TestColorAutoDisabledWhenStdoutIsBuffer(t *testing.T) { } } +// ColorAuto resolves per stream at construction: severity levels follow +// stderr, so when stdout is a pipe but stderr is a TTY, warn / error lines +// stay colored while info lines stay plain. This is the `hmn ... | less` +// case from user feedback. +func TestColorAutoPerStreamTTYDetection(t *testing.T) { + slave, master, cleanupPTY := openPTY(t) + defer cleanupPTY() + + var stdout bytes.Buffer // piped stdout: not a TTY + + log := loglayer.New(loglayer.Config{ + Transport: clitr.New(clitr.Config{ + Stdout: &stdout, + Stderr: slave, // real terminal + Color: clitr.ColorAuto, + }), + }) + + log.Info("plain") + if strings.ContainsRune(stdout.String(), 0x1b) { + t.Errorf("info (stdout, non-TTY) should not have ANSI; got %q", stdout.String()) + } + + log.Error("colored") + + // The severity line lands in the PTY slave; read it back from the + // master side and verify it carries ANSI. + out := "" + for range 5 { + chunk := readTimeout(t, master, make([]byte, 256)) + out += chunk + if strings.Contains(out, "\n") { + break + } + } + if !strings.ContainsRune(out, 0x1b) { + t.Errorf("error (stderr, TTY) should have ANSI under ColorAuto; got %q", out) + } + if !strings.Contains(out, "error:") || !strings.Contains(out, "colored") { + t.Errorf("severity line missing from stderr output; got %q", out) + } +} + +// Both streams are real *os.File values, but only stderr is a TTY. Each +// stream's resolution must be independent: warn stays colored (stderr +// TTY) even though stdout is a non-TTY file. This pins the decision path +// the per-stream resolution was built for. +func TestColorAutoPerStreamBothFilesOneTTY(t *testing.T) { + slave, master, cleanupPTY := openPTY(t) + defer cleanupPTY() + + stdoutF, err := os.CreateTemp(t.TempDir(), "piped-stdout") + if err != nil { + t.Fatalf("create temp stdout: %v", err) + } + defer stdoutF.Close() + + log := loglayer.New(loglayer.Config{ + Transport: clitr.New(clitr.Config{ + Stdout: stdoutF, // real file, not a TTY + Stderr: slave, // real terminal + Color: clitr.ColorAuto, + }), + }) + + log.Info("plain") + log.Warn("colored") + + // stderr is the TTY: warn must carry ANSI. + out := "" + for range 5 { + chunk := readTimeout(t, master, make([]byte, 256)) + out += chunk + if strings.Contains(out, "\n") { + break + } + } + if !strings.ContainsRune(out, 0x1b) { + t.Errorf("warn (stderr, TTY) should have ANSI under ColorAuto; got %q", out) + } + if !strings.Contains(out, "warning:") || !strings.Contains(out, "colored") { + t.Errorf("warn line missing from stderr output; got %q", out) + } + + // stdout is a non-TTY file: info must stay plain. + infoBytes, _ := os.ReadFile(stdoutF.Name()) + if strings.ContainsRune(string(infoBytes), 0x1b) { + t.Errorf("info (stdout, non-TTY file) should not have ANSI; got %q", infoBytes) + } + if !strings.Contains(string(infoBytes), "plain") { + t.Errorf("info line missing from stdout file; got %q", infoBytes) + } +} + +// The inverse case: stdout is a TTY but stderr is a pipe. Debug lines +// (stdout stream) carry ANSI; warn lines (stderr stream) stay plain. +// Info is not used here: its default palette color is nil ("no color: +// plain stdout"), so Info lines are plain by design on any stream. +func TestColorAutoPerStreamTTYDetectionInverse(t *testing.T) { + slave, master, cleanupPTY := openPTY(t) + defer cleanupPTY() + + var stderr bytes.Buffer // piped stderr: not a TTY + + log := loglayer.New(loglayer.Config{ + Transport: clitr.New(clitr.Config{ + Stdout: slave, // real terminal + Stderr: &stderr, + Color: clitr.ColorAuto, + }), + }) + + // Exercise the bug that per-stream resolution fixes: warn must be + // uncolored even though stdout (the TTY) would say "color on". + log.Warn("plain") + if strings.ContainsRune(stderr.String(), 0x1b) { + t.Errorf("warn (stderr, non-TTY) should not have ANSI; got %q", stderr.String()) + } + + log.Debug("colored") + + if strings.ContainsRune(stderr.String(), 0x1b) { + t.Errorf("warn (stderr, non-TTY) should not have ANSI; got %q", stderr.String()) + } + // Read the debug line back from the pty (debug writes to the slave). + // A single read may return a partial chunk; keep reading until the + // line terminator arrives. + out := "" + for range 5 { + chunk := readTimeout(t, master, make([]byte, 256)) + out += chunk + if strings.Contains(out, "\n") { + break + } + } + if !strings.ContainsRune(out, 0x1b) { + t.Errorf("debug (stdout, TTY) should have ANSI under ColorAuto; got %q", out) + } + if !strings.Contains(out, "colored") { + t.Errorf("debug line missing from stdout output; got %q", out) + } +} + +func TestMessageFnFullTakeover(t *testing.T) { + // With MessageFn set, only its output is emitted: the assembled + // message and its logfmt/table body are replaced wholesale. The + // hook still receives the full TransportParams (level, messages, + // assembled Data) to format from. + log, stdout, _ := makeLogger(t, clitr.Config{ + MessageFn: func(params loglayer.TransportParams) string { + return fmt.Sprintf("[%s] %v fields=%d", params.LogLevel, params.Messages[0], len(params.Data)) + }, + }) + + log.WithFields(loglayer.Fields{"ge": "eu-west"}).Info("ignored message") + + got := strings.TrimRight(stdout.String(), "\n") + if !strings.Contains(got, "[info] ignored message fields=1") { + t.Errorf("MessageFn output missing: %q", got) + } + if strings.Contains(got, "ge=eu-west") { + t.Errorf("logfmt body should be replaced by MessageFn, got %q", got) + } +} + +func TestMessageFnEmptyFallsBack(t *testing.T) { + // An empty return opts out per entry: the normal rendering wins. + log, stdout, _ := makeLogger(t, clitr.Config{ + MessageFn: func(params loglayer.TransportParams) string { + return "" + }, + }) + + log.WithMetadata(loglayer.Metadata{"user": "alice"}).Info("regular line") + + got := strings.TrimRight(stdout.String(), "\n") + if !strings.Contains(got, "regular line") { + t.Errorf("fallback rendering missing: %q", got) + } +} + +func TestMessageFnSanitized(t *testing.T) { + // The takeover string goes through the same sanitizer as any other + // rendered body, so a hostile MessageFn can't forge lines or + // smuggle terminal escapes. The contract: no ESC (0x1b) control + // byte reaches the output. CSI-family sequences like \x1b[2J and + // \x1b[K lose their ESC, so the terminal never interprets the rest + // as a control sequence (the trailing text is inert). + for _, hostile := range []string{ + "clean\x1b[31mred\x1b[0m", // classic SGR color + "ok\x1b[2J", // clear screen + "ok\x1b[K", // erase to end of line + } { + hostile := hostile + log, stdout, _ := makeLogger(t, clitr.Config{ + MessageFn: func(_ loglayer.TransportParams) string { return hostile }, + }) + log.Info("x") + got := stdout.String() + if strings.ContainsRune(got, 0x1b) { + t.Errorf("ANSI ESC from MessageFn leaked through: %q", got) + } + } +} + func TestSanitizesMessages(t *testing.T) { // CRLF and ANSI ESC must be scrubbed so a user-controlled // message can't smuggle terminal escapes or forge log lines. diff --git a/transports/cli/go.mod b/transports/cli/go.mod index b2e19f5..5a0599e 100644 --- a/transports/cli/go.mod +++ b/transports/cli/go.mod @@ -6,10 +6,10 @@ require ( github.com/fatih/color v1.19.0 github.com/mattn/go-isatty v0.0.20 go.loglayer.dev/v2 v2.1.0 + golang.org/x/sys v0.42.0 ) require ( github.com/goccy/go-json v0.10.6 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - golang.org/x/sys v0.42.0 // indirect ) diff --git a/transports/gcplogging/go.mod b/transports/gcplogging/go.mod index 69017f8..bfb02f1 100644 --- a/transports/gcplogging/go.mod +++ b/transports/gcplogging/go.mod @@ -24,20 +24,20 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.8.0 // indirect google.golang.org/api v0.214.0 // indirect google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/transports/gcplogging/go.sum b/transports/gcplogging/go.sum index 5eb7a4c..2865774 100644 --- a/transports/gcplogging/go.sum +++ b/transports/gcplogging/go.sum @@ -14,15 +14,15 @@ cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0 cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -62,45 +62,45 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.214.0 h1:h2Gkq07OYi6kusGOaT/9rnNljuXmqPnaig7WGPmKbwA= google.golang.org/api v0.214.0/go.mod h1:bYPpLG8AyeMWwDU6NXoB00xC0DFkikVvd5MfwoxjLqE= google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/types.go b/types.go index 4dfcd3d..1d72506 100644 --- a/types.go +++ b/types.go @@ -75,6 +75,20 @@ type Config struct { // (logging on). Equivalent to calling DisableLogging() after construction. Disabled bool + // Level is the initial level threshold, applied at construction exactly + // like SetLevel: it enables the configured level and everything above + // it, and disables everything below. + // + // Zero value means "no override": every level is enabled (the default). + // Levels start at LogLevelTrace = 5, so zero is unambiguous. Prefer + // this over a post-construction SetLevel when the level is known at + // setup time (e.g. from a --verbose / --quiet flag), so there is no + // window where the logger is live at the wrong level. + // + // Distinct from transport.BaseConfig.Level, which is a per-transport + // minimum checked in addition to the logger's own level state. + Level LogLevel + // ErrorSerializer customizes how errors are serialized into the log data. ErrorSerializer ErrorSerializer