Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/dx-loglayer-feedback.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 8 additions & 0 deletions .changeset/gcplogging-grpc-vuln-fix.md
Original file line number Diff line number Diff line change
@@ -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`.
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ log.WithPrefix("[my-app]").
"path": "/",
"reqId": "1234"
},
"metadata": {
"some": "data"
},
"err": {
"message": "test"
},
"metadata": {
"some": "data"
}
}
```
Expand Down
7 changes: 7 additions & 0 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions docs/src/cheatsheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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

Expand Down
41 changes: 39 additions & 2 deletions docs/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"}
// }
```

Expand All @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion docs/src/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
:::
Expand All @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
```

Expand Down
4 changes: 2 additions & 2 deletions docs/src/integrations/loghttp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/src/integrations/sloghandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions docs/src/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
```

Expand Down Expand Up @@ -100,8 +100,8 @@ log.
{
"msg": "Request failed",
"context": { "requestId": "abc-123" },
"metadata": { "duration": 150 },
"err": { "message": "timeout" }
"err": { "message": "timeout" },
"metadata": { "duration": 150 }
}
```

Expand Down
4 changes: 2 additions & 2 deletions docs/src/logging-api/_partials/combining-example.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```
4 changes: 4 additions & 0 deletions docs/src/logging-api/basic-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<joined message>)`. 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.
Expand Down
10 changes: 5 additions & 5 deletions docs/src/logging-api/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
Expand All @@ -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:
Expand Down Expand Up @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions docs/src/logging-api/go-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading