Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3a830f3
docs: design spec for v3 uniform metadata nesting + docs sweep
Aug 21, 2026
951e76d
feat: default MetadataFieldName to metadata with FlattenMetadata opt-out
Aug 21, 2026
8d15fc1
feat: move core module to go.loglayer.dev/v3
Aug 22, 2026
4f50330
test(transport): update contract suite for metadata default nesting
Aug 22, 2026
b55802a
test(transport): thread opts.Level through fake transport factory
Aug 22, 2026
cbecce4
refactor(transporttest): simplify zero-value Level in contract fake
Aug 22, 2026
afc2eb1
build: trim go.work to core for PR A (restored in the v3 sweep PR)
Aug 22, 2026
492b6a5
docs: v3 migration guide, configuration updates for metadata default
Aug 22, 2026
4916b83
docs: metadata uniform-nesting rules, MetadataOnly KV guidance
Aug 22, 2026
a12b9d0
docs: soften cli metadata rendering note in KV-only example
Aug 22, 2026
93c867e
docs: sweep transport pages for v3 metadata default, fatal and ID gui…
Aug 22, 2026
3e42b81
docs: whats-new, cheat sheet, llms references for v3
Aug 22, 2026
04ac453
docs: accuracy sweep, fix stale v3 references and dead anchor
Aug 22, 2026
3525576
chore: changeset for v3 core release
Aug 22, 2026
dfb4951
fix(doc): correct quickstart import path and transport-ID guidance
Aug 22, 2026
84caec6
docs: fix Schema GoDoc field count
Aug 22, 2026
d1f9a45
docs: sweep plugin/integration core imports to v3
Aug 22, 2026
198cc47
docs: update plugin interface links to v3 pkg.go.dev
Aug 22, 2026
d0dddb9
docs: consolidate migration guides into one page with redirects
Aug 22, 2026
49b5092
docs: flag interim v2-transport state on code-first pages
Aug 22, 2026
26759ed
ci: gate sub-module checks behind CORE_ONLY during core v3 transition
Aug 22, 2026
4868be5
fix(doc): point changeset migration link at consolidated guide
Aug 22, 2026
f70b3f5
fix(ci): clean xargs parallel invocation and go.work whitespace
Aug 22, 2026
c2e0422
docs: flag interim v2 metadata placement on transport pages
Aug 22, 2026
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
5 changes: 5 additions & 0 deletions .changeset/swift-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"go.loglayer.dev": major
---

**Metadata now nests by default.** `Config.MetadataFieldName` resolves to `"metadata"` when empty, so map and struct metadata render uniformly under that key across every transport. Restore the v2 root-flattening shape with `Config.FlattenMetadata: true`. The core module path moves from `go.loglayer.dev/v2` to `go.loglayer.dev/v3`. See [Migrating to v3](/migrating#migrating-to-v3).
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ jobs:
if: github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore(release):')
name: Test (Go ${{ matrix.go }})
runs-on: ubuntu-latest
# Core-only mode: while the core is on its unpublished v3 path, the
# still-v2 sub-modules cannot build against it (their replace points
# at the repo root). Restrict the foreach ops to the root module;
# the sweep PR that moves sub-modules to v3 removes this and the
# per-module steps below.
env:
CORE_ONLY: '1'
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -58,30 +65,40 @@ jobs:

# OTel transport and plugin live in their own modules so the OTel
# SDK's Go floor doesn't bind the main module. Test each separately.
# Skipped in core-only mode (CORE_ONLY=1): these modules still
# require the v2 core and cannot build while the root is the
# unpublished v3. The sweep PR removes the skip.
- name: Test (transports/otellog)
if: env.CORE_ONLY != '1'
working-directory: transports/otellog
run: go test -race -count=1 ./...

- name: Livetest (transports/otellog, real OTel SDK)
if: env.CORE_ONLY != '1'
working-directory: transports/otellog
run: go test -tags=livetest -race -count=1 ./...

- name: Test (plugins/oteltrace)
if: env.CORE_ONLY != '1'
working-directory: plugins/oteltrace
run: go test -race -count=1 ./...

- name: Livetest (plugins/oteltrace, real OTel TracerProvider)
if: env.CORE_ONLY != '1'
working-directory: plugins/oteltrace
run: go test -tags=livetest -race -count=1 ./...

- name: Livetest (Datadog dd-trace-go integration)
if: env.CORE_ONLY != '1'
working-directory: plugins/datadogtrace/livetest
run: go test -race -count=1 ./...

# Multi-module examples: syntax check only (running them produces
# stdout output we don't need in CI). -o /dev/null avoids writing
# the example binary into the workspace.
# the example binary into the workspace. Skipped in core-only mode
# for the same v2-core reason as the OTel modules above.
- name: Build (examples/otel-end-to-end)
if: env.CORE_ONLY != '1'
working-directory: examples/otel-end-to-end
run: go build -o /dev/null ./...

Expand All @@ -93,6 +110,9 @@ jobs:
# Pin instead of @latest so a new staticcheck release with new
# checks doesn't surprise CI. Bump deliberately.
STATICCHECK_VERSION: '2026.1'
# Core-only mode: see the test job's comment. The v2 sub-modules
# can't be analyzed while the root is the unpublished v3 core.
CORE_ONLY: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
Expand Down Expand Up @@ -132,6 +152,9 @@ jobs:
runs-on: ubuntu-latest
env:
GOVULNCHECK_VERSION: 'v1.7.0'
# Core-only mode: same v2-sub-module gate as the test job. The
# sweep PR removes this.
CORE_ONLY: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
Expand Down
4 changes: 2 additions & 2 deletions bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ package loglayer_test
import (
"testing"

"go.loglayer.dev/v2"
"go.loglayer.dev/v2/transport/benchtest"
"go.loglayer.dev/v3"
"go.loglayer.dev/v3/transport/benchtest"
)

type noopTransport struct{}
Expand Down
6 changes: 3 additions & 3 deletions concurrency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import (
"sync/atomic"
"testing"

"go.loglayer.dev/v2"
"go.loglayer.dev/v2/internal/lltest"
"go.loglayer.dev/v2/transport"
"go.loglayer.dev/v3"
"go.loglayer.dev/v3/internal/lltest"
"go.loglayer.dev/v3/transport"
)

func TestConcurrentEmission_SimpleMessage(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"errors"
"testing"

"go.loglayer.dev/v2"
"go.loglayer.dev/v3"
)

func TestBuild_NoTransport(t *testing.T) {
Expand Down
6 changes: 3 additions & 3 deletions dispatch_edge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import (
"errors"
"testing"

"go.loglayer.dev/v2"
"go.loglayer.dev/v2/internal/lltest"
"go.loglayer.dev/v2/transport"
"go.loglayer.dev/v3"
"go.loglayer.dev/v3/internal/lltest"
"go.loglayer.dev/v3/transport"
)

// dispatch_edge_test.go covers edge cases of the processLog dispatch path
Expand Down
14 changes: 10 additions & 4 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
// fluent builder API. The core defines the LogLayer type, the Transport
// and Plugin interfaces, and the dispatch pipeline. Concrete transports
// (zap, zerolog, slog, charmlog, OTel, etc.) ship as separately-versioned
// sub-modules under go.loglayer.dev/transports/<name>/v2.
// sub-modules under go.loglayer.dev/transports/<name>, with a /vN suffix
// for sub-modules on their own major version (e.g. transports/structured/v2).
//
// Full docs: https://go.loglayer.dev
//
// # Quickstart
//
// import (
// "go.loglayer.dev/v2"
// "go.loglayer.dev/v3"
// "go.loglayer.dev/transports/structured/v2"
// )
//
Expand All @@ -20,6 +21,10 @@
// WithMetadata(loglayer.Metadata{"durationMs": 42}).
// Info("served")
//
// Note: transports are still at their v2 paths in this release; the combo
// above compiles once the transport v3 bumps land in the follow-up release.
// Install go.loglayer.dev/v3 alone first, or wait for those bumps.
//
// # Three data shapes
//
// LogLayer separates persistent from per-call data on purpose. Pick the
Expand All @@ -29,8 +34,9 @@
// WithFields and it appears on every subsequent log entry. Use for
// request IDs, user IDs, and anything request-scoped.
// - Metadata (any): single log call only. Use for per-event payloads
// such as durations, counters, or structs. Maps merge at the entry
// root; other values nest under Config.MetadataFieldName.
// such as durations, counters, or structs. Metadata nests under the
// Config.MetadataFieldName key uniformly ("metadata" by default) unless
// Config.FlattenMetadata restores the legacy per-transport placement.
// - Context (context.Context): single log call only. Transports that
// understand context (OTel, slog) read trace IDs and deadlines from
// it; others ignore it.
Expand Down
4 changes: 2 additions & 2 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ gtag('config', '${gaMeasurementId}');`,
outline: { level: [2, 3] },
nav: [
{ text: '<img alt="Latest version" src="https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=v*&amp;sort=date&amp;label=version&amp;style=flat-square&color=blue" />', link: 'https://github.com/loglayer/loglayer-go/releases' },
{ text: '<img alt="Go Reference" src="https://pkg.go.dev/badge/go.loglayer.dev/v2.svg" />', link: 'https://pkg.go.dev/go.loglayer.dev/v2' },
{ text: '<img alt="Go Reference" src="https://pkg.go.dev/badge/go.loglayer.dev/v3.svg" />', link: 'https://pkg.go.dev/go.loglayer.dev/v3' },
{ text: "What's New", link: '/whats-new' },
{ text: 'Get Started', link: '/getting-started' },
{ text: 'TypeScript Version', link: 'https://loglayer.dev' },
Expand All @@ -115,7 +115,7 @@ gtag('config', '${gaMeasurementId}');`,
{ text: 'For TypeScript Developers', link: '/for-typescript-developers' },
{ text: 'Use with AI / LLMs', link: '/llms' },
{ text: "What's New", link: '/whats-new' },
{ text: 'Migrating to v2', link: '/migrating-to-v2' },
{ text: 'Migration Guide', link: '/migrating' },
],
},
{
Expand Down
2 changes: 1 addition & 1 deletion docs/src/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ With LogLayer, swapping the underlying transport is a one-line change in `New()`

```go
import (
"go.loglayer.dev/v2"
"go.loglayer.dev/v3"
"go.loglayer.dev/integrations/sloghandler/v2"
"go.loglayer.dev/transports/structured/v2"
llzero "go.loglayer.dev/transports/zerolog/v2"
Expand Down
12 changes: 10 additions & 2 deletions docs/src/cheatsheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ description: One-page quick reference of the LogLayer for Go API.

## At a Glance

::: warning Interim state: transports are still on v2
This example pairs the v3 core with the structured transport's `v2` path. The transports move to `/v3` in the follow-up release; until then this exact import combo does not compile. Install the v3 core alone first, or wait for the transport v3 bumps.
:::

```go
import (
"go.loglayer.dev/v2"
"go.loglayer.dev/v3"
"go.loglayer.dev/transports/structured/v2"
)

Expand Down Expand Up @@ -60,6 +64,8 @@ log.Fatal("...") // calls os.Exit(1) by default; set Config.DisableFatalExit to
log.Panic("...") // calls panic(joined-message) after dispatch; recoverable
```

Metadata nests under `"metadata"` by default; `Config.FlattenMetadata: true` restores the v2 root-flattening shape. See [MetadataFieldName](/configuration#metadatafieldname) and [FlattenMetadata](/configuration#flattenmetadata).

Each method takes `...any`, joined with a space.

For `fmt.Sprintf`-style format strings, register the optional [`fmtlog`](https://pkg.go.dev/go.loglayer.dev/plugins/fmtlog/v2) plugin:
Expand All @@ -78,6 +84,8 @@ Without the plugin, multi-arg messages are space-joined.

## Metadata

Metadata nests under the `"metadata"` key by default (`Config.MetadataFieldName`); set `Config.FlattenMetadata: true` for the v2 root-flattening shape. See [MetadataFieldName](/configuration#metadatafieldname) and [FlattenMetadata](/configuration#flattenmetadata).

```go
// Struct (preferred when the shape is fixed; cheaper, type-checked)
type User struct {
Expand Down Expand Up @@ -336,7 +344,7 @@ log.Info("served")
// {"level":"info","time":"...","msg":"served","source":{"function":"main.handler","file":"/app/main.go","line":42}}
```

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).
Off by default. Costs ~600 ns / +5 allocs per emission when on (see [Benchmarks](/benchmarks#caller-info-config-source)). The slog Handler forwards `slog.Record.PC` automatically (no capture cost on the slog path).

## slog Interop

Expand Down
47 changes: 18 additions & 29 deletions docs/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ type Config struct {
ErrorFieldName string // key for serialized error (default: "err")
CopyMsgOnOnlyError bool // copy err.Error() into the message in ErrorOnly
FieldsKey string // nest fields under this key (default: merged at root)
MetadataFieldName string // nest metadata under this key (default: each transport's policy)
MetadataFieldName string // nest metadata under this key (default: "metadata")
FlattenMetadata bool // v2 shape opt-out: flatten map metadata at root when MetadataFieldName is unset
MuteFields bool // disable fields in output
MuteMetadata bool // disable metadata in output
DisableFatalExit bool // skip os.Exit(1) after a Fatal log
Expand All @@ -48,7 +49,7 @@ 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:
For config loaded at runtime, use `Build`: `Build` returns an `error` instead of panicking, with the same validation as `New` (no transport, both `Transport` and `Transports` set). Keep `New` for programmatic setup where a bad config is a programmer error and panicking at construction time fails loudly:

```go
log, err := loglayer.Build(loglayer.Config{
Expand All @@ -61,6 +62,8 @@ if err != nil {

Both report `loglayer.ErrNoTransport` when no transport is configured (via `errors.Is` on the `Build` error).

Libraries that need a logger should accept one from their caller instead of calling `New` or `Build` themselves; the consumer knows their own config source.

## Transports

Set exactly one of `Transport` or `Transports`:
Expand Down Expand Up @@ -214,47 +217,33 @@ See [Fields](/logging-api/fields).

## MetadataFieldName

By default, transports use their own placement policy for metadata: renderer transports (`structured`, `console`) flatten map metadata at the root and JSON-roundtrip non-map values; wrapper transports (`zap`, `zerolog`, `charmlog`, `phuslu`, `logrus`, `slog`, `otellog`, `sentry`) flatten map metadata as individual attributes and nest non-map values under a hardcoded `"metadata"` key.

Set `MetadataFieldName` to nest **both** map and non-map metadata under a single configurable key uniformly:
By default (v3), the entry's metadata nests under the `"metadata"` key uniformly, for both map and non-map values, across every transport. Set this to nest under a different key:

```go
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
MetadataFieldName: "metadata",
MetadataFieldName: "payload",
})

log.WithMetadata(loglayer.Metadata{"userId": 1234}).Info("served")
// {"msg":"served","metadata":{"userId":1234}}

log.WithMetadata(struct{ ID int }{ID: 7}).Info("user")
// {"msg":"user","metadata":{"ID":7}}
// {"msg":"served","payload":{"userId":1234}}
```

This produces the symmetric three-knob shape alongside `FieldsKey` and `ErrorFieldName`:
## FlattenMetadata

Set `FlattenMetadata: true` to restore the v2 shape: map metadata merges at the root, and non-map metadata follows each transport's historical placement. Ignored when `MetadataFieldName` is explicitly set.

```go
loglayer.New(loglayer.Config{
FieldsKey: "context",
MetadataFieldName: "metadata",
ErrorFieldName: "error",
Transport: structured.New(structured.Config{}),
FlattenMetadata: true,
})

log = log.WithFields(loglayer.Fields{"service": "api"})
log.WithMetadata(loglayer.Metadata{"userId": "1234"}).
WithError(errors.New("boom")).
Error("user action failed")
// {
// "msg": "user action failed",
// "context": {"service": "api"},
// "error": {"message": "boom"},
// "metadata":{"userId": "1234"}
// }
log.WithMetadata(loglayer.Metadata{"userId": 1234}).Info("served")
// {"msg":"served","userId":1234}
```

When empty (default), each transport keeps its existing default placement. The setting is published to every transport (and dispatch-time plugin hooks) via `loglayer.Schema`; transports honor it uniformly.

See [Metadata](/logging-api/metadata).
The resolved key ("metadata", your override, or unset with `FlattenMetadata`) is published to every transport (and dispatch-time plugin hooks) via `loglayer.Schema`; transports honor it uniformly. See [Metadata](/logging-api/metadata).

## DisableFatalExit

Expand All @@ -270,8 +259,8 @@ 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).
::: warning A future contributor calling log.Fatal in a worker kills the process
In service code with deferred cleanup, or from worker goroutines, a bare `log.Fatal(...)` kills the process immediately without running `defer`s. Set `DisableFatalExit: true` on the root config for long-running services and use `Error` in workers (or call `log.Fatal` only from a coordinator that drains first). See [Adjusting Log Levels](/logging-api/adjusting-log-levels) for the runtime level toggles you can wire up instead.
:::

## MuteFields / MuteMetadata
Expand Down
8 changes: 5 additions & 3 deletions docs/src/for-typescript-developers.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,13 @@ TypeScript's `@loglayer/transport-pino`, `@loglayer/plugin-redaction`, etc. are

| TypeScript | Go |
|----------------------------------|-----------------------------------------------|
| `loglayer` | `go.loglayer.dev/v2` (core + stdlib renderers) |
| `loglayer` | `go.loglayer.dev/v3` (core + stdlib renderers) |
| `@loglayer/transport-zerolog` | `go.loglayer.dev/transports/zerolog/v2` |
| `@loglayer/transport-datadog` | `go.loglayer.dev/transports/datadog/v2` |
| `@loglayer/integration-elysia` | `go.loglayer.dev/integrations/loghttp/v2` (etc.) |

Transports and integrations keep their current paths until each ships its own v3 bump; check the [Transports overview](/transports/) and the [loghttp](/integrations/loghttp) / [sloghandler](/integrations/sloghandler) pages for each module's current path.

`go get` each module you actually need; the dependency graph stays focused on whatever you imported.

## Plugins
Expand Down Expand Up @@ -157,7 +159,7 @@ log.AddPlugin(redact.New(redact.Config{
}))
```

See [Plugins](/plugins/) for the full lifecycle, hook ordering, and nil-return semantics. Third-party plugins can use [`utils/maputil`](https://pkg.go.dev/go.loglayer.dev/v2/utils/maputil) for the same reflection-based deep-clone primitive that the redact plugin uses.
See [Plugins](/plugins/) for the full lifecycle, hook ordering, and nil-return semantics. Third-party plugins can use [`utils/maputil`](https://pkg.go.dev/go.loglayer.dev/v3/utils/maputil) for the same reflection-based deep-clone primitive that the redact plugin uses.

## Groups

Expand Down Expand Up @@ -197,7 +199,7 @@ If any of these are blockers for your use case, open an issue at [github.com/log
// log.withMetadata({ duration: 42 }).withError(err).info('did the thing');

import (
"go.loglayer.dev/v2"
"go.loglayer.dev/v3"
"go.loglayer.dev/transports/structured/v2"
)

Expand Down
Loading
Loading