diff --git a/.changeset/swift-metadata.md b/.changeset/swift-metadata.md new file mode 100644 index 0000000..ba3ae4f --- /dev/null +++ b/.changeset/swift-metadata.md @@ -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). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed60284..636b6c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 ./... @@ -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 @@ -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 diff --git a/bench_test.go b/bench_test.go index 85a72d9..689d3f7 100644 --- a/bench_test.go +++ b/bench_test.go @@ -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{} diff --git a/concurrency_test.go b/concurrency_test.go index 1e668fe..7d6d104 100644 --- a/concurrency_test.go +++ b/concurrency_test.go @@ -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) { diff --git a/coverage_test.go b/coverage_test.go index 5439a8b..12a9624 100644 --- a/coverage_test.go +++ b/coverage_test.go @@ -9,7 +9,7 @@ import ( "errors" "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) func TestBuild_NoTransport(t *testing.T) { diff --git a/dispatch_edge_test.go b/dispatch_edge_test.go index 2d1cbec..c0c4af9 100644 --- a/dispatch_edge_test.go +++ b/dispatch_edge_test.go @@ -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 diff --git a/doc.go b/doc.go index 7be4a9c..f63386b 100644 --- a/doc.go +++ b/doc.go @@ -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//v2. +// sub-modules under go.loglayer.dev/transports/, 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" // ) // @@ -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 @@ -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. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6a633d5..d3e7e88 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -98,7 +98,7 @@ gtag('config', '${gaMeasurementId}');`, outline: { level: [2, 3] }, nav: [ { text: 'Latest version', link: 'https://github.com/loglayer/loglayer-go/releases' }, - { text: 'Go Reference', link: 'https://pkg.go.dev/go.loglayer.dev/v2' }, + { text: 'Go Reference', 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' }, @@ -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' }, ], }, { diff --git a/docs/src/benchmarks.md b/docs/src/benchmarks.md index 1b96797..0eb85c2 100644 --- a/docs/src/benchmarks.md +++ b/docs/src/benchmarks.md @@ -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" diff --git a/docs/src/cheatsheet.md b/docs/src/cheatsheet.md index 060fddd..b7cde54 100644 --- a/docs/src/cheatsheet.md +++ b/docs/src/cheatsheet.md @@ -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" ) @@ -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: @@ -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 { @@ -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 diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 111897a..f417bbe 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -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 @@ -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{ @@ -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`: @@ -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 @@ -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 diff --git a/docs/src/for-typescript-developers.md b/docs/src/for-typescript-developers.md index 4ecd900..30f2ae5 100644 --- a/docs/src/for-typescript-developers.md +++ b/docs/src/for-typescript-developers.md @@ -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 @@ -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 @@ -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" ) diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 3deca5a..ecfb147 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -5,17 +5,23 @@ description: Install LogLayer, pick a transport, and write your first structured # Getting Started -LogLayer for Go targets **Go 1.25+** for the main module: `go.loglayer.dev/v2`. Most transports are sub-packages of that module, so you only pull in dependencies for the transports you actually use. Individual transports and plugins call out any stricter requirement on their per-page docs. +LogLayer for Go targets **Go 1.25+** for the main module: `go.loglayer.dev/v3`. Most transports are sub-packages of that module, so you only pull in dependencies for the transports you actually use. Individual transports and plugins call out any stricter requirement on their per-page docs. ## Installation -LogLayer ships as a multi-module repo: the core lives at `go.loglayer.dev/v2`, and every transport and plugin is its own independently-versioned sub-module. You install the core plus only the transports you actually use. +LogLayer ships as a multi-module repo: the core lives at `go.loglayer.dev/v3`, and every transport and plugin is its own independently-versioned sub-module. You install the core plus only the transports you actually use. ```sh -go get go.loglayer.dev/v2 +go get go.loglayer.dev/v3 go get go.loglayer.dev/transports/structured/v2 ``` +::: warning Interim state: transports are still on v2 +This release moves the core to `go.loglayer.dev/v3`; the transports keep their `v2` paths until the follow-up release that bumps them to `v3`. Until then, the examples below that pair the v3 core with a `v2` transport path do not compile together. Install the v3 core alone first, or wait for the transport v3 bumps before copying the full examples. +::: + +Transports and plugins keep their own versioned paths; the structured transport moves to `/v3` in a follow-up release. + ## Basic Usage with the Structured Transport The simplest way to start is the [Structured Transport](/transports/structured), which writes one JSON object per log entry to `os.Stdout`: @@ -25,17 +31,21 @@ package main import ( "errors" + "fmt" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/structured/v2" ) func main() { - log := loglayer.New(loglayer.Config{ - Transport: structured.New(structured.Config{}), - FieldsKey: "context", - MetadataFieldName: "metadata", + log, err := loglayer.Build(loglayer.Config{ + Transport: structured.New(structured.Config{}), + FieldsKey: "context", }) + if err != nil { + fmt.Printf("configure logger: %v\n", err) + return + } // Basic logging log.Info("Hello world!") @@ -56,18 +66,9 @@ 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): +The example above uses `loglayer.Build` because it showcases runtime config: when the config comes from a runtime source (env vars, config file), `Build` handles errors explicitly instead of panicking. For programmatic setup, `loglayer.New` panics on misconfiguration (no transport configured) and fits where a bad config is a programmer error. 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) -} -``` +The example sets `FieldsKey` to nest fields under their own key; metadata nests under `"metadata"` by default. See [Configuration](/configuration) for every knob on `loglayer.Config`: error serialization, field/metadata placement, prefix, source capture, group routing, fatal-exit control, and more. ::: 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). @@ -103,7 +104,7 @@ import ( zlog "github.com/rs/zerolog" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" llzero "go.loglayer.dev/transports/zerolog/v2" ) diff --git a/docs/src/index.md b/docs/src/index.md index df4ed1e..23906bf 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -39,21 +39,24 @@ features: ## Quick Example +::: 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 package main import ( "errors" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/structured/v2" ) func main() { log := loglayer.New(loglayer.Config{ - Transport: structured.New(structured.Config{}), - FieldsKey: "context", - MetadataFieldName: "metadata", + Transport: structured.New(structured.Config{}), + FieldsKey: "context", }) // WithFields returns a NEW logger; assign it. diff --git a/docs/src/integrations/loghttp.md b/docs/src/integrations/loghttp.md index 07fa449..0be09f5 100644 --- a/docs/src/integrations/loghttp.md +++ b/docs/src/integrations/loghttp.md @@ -21,7 +21,7 @@ go get go.loglayer.dev/integrations/loghttp/v2 import ( "net/http" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/integrations/loghttp/v2" "go.loglayer.dev/transports/structured/v2" ) diff --git a/docs/src/integrations/sloghandler.md b/docs/src/integrations/sloghandler.md index 78b6100..2890766 100644 --- a/docs/src/integrations/sloghandler.md +++ b/docs/src/integrations/sloghandler.md @@ -27,7 +27,7 @@ This is the **slog → loglayer** direction. If you want the opposite (use logla import ( "log/slog" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/integrations/sloghandler/v2" "go.loglayer.dev/plugins/redact/v2" "go.loglayer.dev/transports/structured/v2" diff --git a/docs/src/llms.md b/docs/src/llms.md index 0309d03..4a46c9b 100644 --- a/docs/src/llms.md +++ b/docs/src/llms.md @@ -56,5 +56,5 @@ If you're not sure, start with `llms-full.txt`. It's the lower-friction option. ## Other ways to feed loglayer to an LLM - **Source code** at [github.com/loglayer/loglayer-go](https://github.com/loglayer/loglayer-go) is small enough to fit in most coding assistants' context. -- **pkg.go.dev** (`pkg.go.dev/go.loglayer.dev/v2`) renders all GoDoc, including type signatures and doc comments. Useful for fact-checking the model's output. +- **pkg.go.dev** (`pkg.go.dev/go.loglayer.dev/v3`) renders all GoDoc, including type signatures and doc comments. Useful for fact-checking the model's output. - **This docs site** is itself indexed by most search-augmented assistants. Asking "from the loglayer.dev Go docs, ..." often works without any setup. diff --git a/docs/src/logging-api/_partials/combining-example.md b/docs/src/logging-api/_partials/combining-example.md index 70132a4..57e0994 100644 --- a/docs/src/logging-api/_partials/combining-example.md +++ b/docs/src/logging-api/_partials/combining-example.md @@ -11,6 +11,8 @@ log.WithMetadata(loglayer.Metadata{"duration_ms": 120}). "msg": "request failed", "requestId": "abc", "err": { "message": "..." }, - "duration_ms": 120 + "metadata": { + "duration_ms": 120 + } } ``` diff --git a/docs/src/logging-api/basic-logging.md b/docs/src/logging-api/basic-logging.md index 7f02fb7..a85002f 100644 --- a/docs/src/logging-api/basic-logging.md +++ b/docs/src/logging-api/basic-logging.md @@ -44,7 +44,7 @@ Numeric ordering matters for `SetLevel`. See [Adjusting Log Levels](/logging-api ## Fatal Exits the Process -`log.Fatal(...)` dispatches the entry to every transport, then calls `os.Exit(1)`. This matches the Go convention used by `log.Fatal` in the standard library, zerolog, zap, logrus, and others: a fatal log marks the process as unrecoverable. +`log.Fatal(...)` dispatches the entry to every transport, then calls `os.Exit(1)`. This matches the Go convention used by `log.Fatal` in the standard library, zerolog, zap, logrus, and others: a fatal log marks the process as unrecoverable. Because the exit runs **after** dispatch, every transport still sees the fatal entry; see each transport's Fatal Behavior section for wrapper-specific caveats. When a `Fatal` entry is dispatched, any transport that implements `io.Closer` is closed along the way (capped by `Config.TransportCloseTimeout`), so async transports (HTTP, Datadog) flush pending entries before the exit. If you don't want the exit (tests, library code, integration scenarios where the host should decide), set `DisableFatalExit: true` on the config: @@ -59,8 +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). +::: warning Fatal is not for long-running services +`os.Exit` does not run `defer`s: deferred cleanup (auto-updater re-exec, graceful shutdown, connection drains) is skipped, and the exit code is always `1`. Config changes or log writes in flight are just lost. For long-running services, prefer `log.Error(...)` at the failure point plus an `os.Exit(1)` at a single coordinator that has drained first. Set `DisableFatalExit: true` at the root if call sites can't be trusted to use `Error`. + +The default Fatal path does flush `io.Closer` transports before exiting (capped by `Config.TransportCloseTimeout`). If you set `DisableFatalExit: true` and call `os.Exit` yourself, that flush no longer runs: close `io.Closer` transports (HTTP, Datadog) explicitly before your own exit to guarantee delivery. ::: ## Panic Panics the Goroutine @@ -116,6 +118,8 @@ log.WithMetadata(...).WithError(err).Error("...") For data that should appear on **every** log from a logger, use `WithFields`. See [Fields](/logging-api/fields). +All examples on this page construct the logger with `loglayer.New`, which panics when the config is invalid (no transport). When the config comes from a runtime source (env vars, a config file, a secrets manager), use `loglayer.Build` instead; it returns `(*LogLayer, error)` with the same validation. See [New vs Build](/configuration#new-vs-build). + ## stdlib `log` and `io.Writer` Bridges Third-party libraries often accept a `*log.Logger` or an `io.Writer` and emit one line per call. Two adapter methods on `*LogLayer` turn each line into a loglayer emission so you can plug those libraries straight into your pipeline: @@ -129,7 +133,7 @@ Drop the result into anything that takes the corresponding type: import ( "net/http" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) srv := &http.Server{ diff --git a/docs/src/logging-api/groups.md b/docs/src/logging-api/groups.md index 08a3cc5..718a059 100644 --- a/docs/src/logging-api/groups.md +++ b/docs/src/logging-api/groups.md @@ -15,10 +15,10 @@ Define groups when creating the logger: ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/structured/v2" "go.loglayer.dev/transports/datadog/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3/transport" ) log := loglayer.New(loglayer.Config{ diff --git a/docs/src/logging-api/metadata.md b/docs/src/logging-api/metadata.md index 312f3c1..519085e 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. 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)). +`WithMetadata` accepts **any** value. The core logger does no conversion; the transport decides how to serialize. In v3, **all** metadata, map or struct, nests under `"metadata"` by default: an empty `Config.MetadataFieldName` resolves to `"metadata"`. v2 placed map metadata at the root and gave non-map values mixed treatment (nested under a hardcoded key in wrapper transports, roundtripped at the root in renderers). Set `Config.FlattenMetadata: true` to restore that v2 shape. See [MetadataFieldName](/configuration#metadatafieldname) and [FlattenMetadata](/configuration#flattenmetadata). ## Struct vs Map: pick the right shape @@ -32,7 +32,7 @@ log.WithMetadata(RequestInfo{ ``` ```json -{"msg":"request handled","method":"POST","path":"/users","duration_ms":45} +{"msg":"request handled","metadata":{"method":"POST","path":"/users","duration_ms":45}} ``` This is the cheaper path on hot code: see [Benchmarks](/benchmarks) for the numbers (struct metadata is ~3 fewer allocations per emission than the map literal below). @@ -54,6 +54,7 @@ The `loglayer.Metadata` named type lets the compiler distinguish it from `Fields ```go log.WithMetadata(loglayer.Metadata{"userId": 42}).Info("user") log.WithMetadata(map[string]any{"userId": 42}).Info("user") +// both render as {"msg":"user","metadata":{"userId":42}} ``` Prefer `loglayer.Metadata` throughout your code so the compiler can flag mix-ups with `Fields`. @@ -70,7 +71,7 @@ Use whichever you prefer; both compile to the same `map[string]any`. LogLayer doesn't clone the map you pass to `WithMetadata`. Mutating it after the call (e.g. reusing the same map for the next emission with a tweak) can bleed into the previous log when a transport retains the value. Build a fresh map per call, or treat the value as read-only once handed off. Structs sidestep this entirely. ::: -[`MetadataFieldName`](/configuration#metadatafieldname) (set on `loglayer.Config`) nests **both** map and non-map metadata under a single configurable key uniformly across every transport. When unset, each transport keeps its existing default placement policy: renderers flatten map metadata at the root, wrappers flatten map metadata as individual attributes and nest non-map values under a hardcoded `"metadata"` key. See each transport's page for its rendering rules, or [Creating Transports → Handling `any` Metadata](/transports/creating-transports#handling-any-metadata) for the placement policies. +[`MetadataFieldName`](/configuration#metadatafieldname) (set on `loglayer.Config`) nests **both** map and non-map metadata under a single configurable key uniformly across every transport. When unset, the core resolves it to `"metadata"`; set `Config.FlattenMetadata: true` to restore the per-transport v2 placement policies (renderers flatten map metadata at the root, wrappers flatten map metadata as individual attributes and nest non-map values under a hardcoded `"metadata"` key). See each transport's page for its rendering rules, or [Creating Transports → Handling `any` Metadata](/transports/creating-transports#handling-any-metadata) for the placement policies. ## Building the Value First @@ -134,14 +135,16 @@ The default level is `Info`. Passing `nil` is a no-op. `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 +// structured: {"level":"info","time":"...","msg":"","metadata":{"status":"healthy","memory":"512MB"}} +// console (always) / cli (with ShowFields): metadata=... (nested as a value under the metadata key) log.MetadataOnly(loglayer.Metadata{ "status": "healthy", "memory": "512MB", }) ``` +`MetadataOnly` emits an entry with no message: the assembled output carries the level and the metadata value, with no message text. The structured transport renders the empty message as `"msg":""` today; omitting `msg` for empty messages ships with the structured transport's v3 release. + 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 diff --git a/docs/src/logging-api/mocking.md b/docs/src/logging-api/mocking.md index 6075bc6..7beb57c 100644 --- a/docs/src/logging-api/mocking.md +++ b/docs/src/logging-api/mocking.md @@ -18,7 +18,7 @@ LogLayer ships a primitive for each. Use this when logs aren't part of what you're testing. It's a drop-in `*loglayer.LogLayer` backed by a discard transport. Every call is accepted but produces no output. ```go -import "go.loglayer.dev/v2" +import "go.loglayer.dev/v3" func TestSomething(t *testing.T) { log := loglayer.NewMock() @@ -61,7 +61,7 @@ Use this when the test's purpose is to verify *what* was logged. The `transports ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" lltest "go.loglayer.dev/transports/testing/v2" ) diff --git a/docs/src/logging-api/multiline.md b/docs/src/logging-api/multiline.md index a2b4bcc..5680871 100644 --- a/docs/src/logging-api/multiline.md +++ b/docs/src/logging-api/multiline.md @@ -10,7 +10,7 @@ description: "Author multi-line message content that survives the cli/pretty/con ## Quickstart ```go -import "go.loglayer.dev/v2" +import "go.loglayer.dev/v3" log.Info(loglayer.Multiline( "Configuration:", diff --git a/docs/src/migrating-to-v2.md b/docs/src/migrating-to-v2.md index ac5d3e5..9cdc3b1 100644 --- a/docs/src/migrating-to-v2.md +++ b/docs/src/migrating-to-v2.md @@ -1,101 +1,9 @@ --- title: Migrating to v2 -description: "Upgrade guide for loglayer-go v2: import paths bump to /v2, the prefix is now exposed on TransportParams.Prefix instead of being folded into Messages[0]." +head: + - - meta + - http-equiv: refresh + content: "0; url=/migrating" --- -# Migrating to v2 - -`loglayer-go` v2 ships two breaking changes: **every import path bumps to `/v2`**, and **the loglayer core no longer mutates `Messages[0]` to fold the `WithPrefix` value into the message text.** The prefix now flows through `TransportParams.Prefix` and each transport decides how to render it. - -This page is the upgrade checklist. - -## Do I have to migrate? - -Not immediately. v1.x continues to work; the v1 module path (`go.loglayer.dev`) keeps resolving to its last v1 tag and the auto-prepend behavior stays intact there. Future feature work and bug fixes ship at v2 (`go.loglayer.dev/v2`), so the migration is the path forward but it's not on a deadline. - -You can migrate one module at a time: a project that uses several `loglayer-go` sub-modules can have v1 imports for some and v2 for others (Go treats `go.loglayer.dev` and `go.loglayer.dev/v2` as separate modules). The catch is that fields shared between modules (e.g. `loglayer.Config` from main) won't bridge between v1 and v2; pick one main module per project. - -## Why this change - -`v1.x` folded the prefix into `Messages[0]` from the core so transports that didn't know about prefixes got the right behavior for free. The downside: transports that DID want to render the prefix differently (separate color, separate JSON field, structured forwarding to underlying loggers) couldn't, because by the time they saw the message it was already mangled. Pulling the prefix into a first-class field unblocks every smarter rendering, at the cost of a one-time import-path migration. - -The new contract also keeps the core out of the business of mutating caller-owned input: in v1, the prefix-prepend silently rewrote the user's `Messages` slice before any transport saw it; in v2, the core passes the slice through untouched and exposes the prefix on its own field. - -## Step 1: bump every import path to `/v2` - -The main module and every sub-module are now versioned at `v2`. Update your `go.mod` requires and your source-file imports. - -```sh -# Run for each sub-module you import -go get go.loglayer.dev/v2 -go get go.loglayer.dev/transports/cli/v2 -go get go.loglayer.dev/transports/zerolog/v2 -go get go.loglayer.dev/plugins/redact/v2 -``` - -In source files: - -```diff - import ( -- "go.loglayer.dev" -- "go.loglayer.dev/transports/zerolog" -- "go.loglayer.dev/plugins/redact" -+ "go.loglayer.dev/v2" -+ "go.loglayer.dev/transports/zerolog/v2" -+ "go.loglayer.dev/plugins/redact/v2" - ) -``` - -The package import name (`loglayer`, `zerolog`, `redact`) does not change; only the import path does. - -## Step 2: most users are done - -For users of the built-in transports who don't write custom transports, nothing else changes. Every built-in transport preserves the v1 user-visible output: `log.WithPrefix("[auth]").Info("hi")` still produces `"[auth] hi"` through every renderer / wrapper / network transport, just like it did in v1. - -The exceptions to "nothing else changes": - -- The **cli transport** opts into smart prefix rendering: the user prefix renders in dim grey while the level prefix and message body keep the level color. If you were using cli with `WithPrefix`, the rendered output is now visually layered. See the [cli transport doc](/transports/cli) for an example. -- The **blank transport** hands `params` straight to your `ShipToLogger` function. If your callback was reading the prefix out of `Messages[0]`, read `params.Prefix` instead. -- If you assert on `testing.LogLine` in tests, the unmangled prefix is also available on `LogLine.Prefix` (new field in v2). Existing assertions on `Messages[0]` keep working because the testing transport calls `JoinPrefixAndMessages` internally. - -## Step 3: custom transports - -If you wrote a custom transport that reads `params.Messages[0]` and relied on the prefix being baked in, you have two paths: - -### Path A: preserve v1 behavior (simplest) - -Call `transport.JoinPrefixAndMessages` at the top of `SendToLogger`: - -```go -import "go.loglayer.dev/v2/transport" - -func (t *Transport) SendToLogger(p loglayer.TransportParams) { - if !t.ShouldProcess(p.LogLevel) { - return - } - p.Messages = transport.JoinPrefixAndMessages(p.Prefix, p.Messages) - // ... your existing rendering logic, unchanged -} -``` - -The helper has fast-path early returns when the prefix is empty, when messages is empty, or when `messages[0]` isn't a string. Per-call cost on a no-prefix logger is one string compare. - -### Path B: smart rendering - -Read `params.Prefix` directly and render it however suits your transport: - -- A renderer transport could color the prefix differently from the message body (see `transports/cli` for an example). -- A structured / JSON transport could emit the prefix as a separate top-level field instead of embedding it in `msg`. -- A wrapper transport could forward the prefix to the underlying logger's structured-field API (`zerolog.Event.Str("prefix", p.Prefix)`, `zap.Field`, etc.). - -## Step 4: custom plugins - -The dispatch-time plugin hook param structs (`BeforeDataOutParams`, `BeforeMessageOutParams`, `TransformLogLevelParams`, `ShouldSendParams`) gained a `Prefix string` field in v1.7.0; that part is unchanged in v2. The only difference: in v1, `params.Messages[0]` carried the prefix folded in; in v2 it doesn't. Plugins that read the message string directly should be aware. - -The prefix is read-only from the plugin's perspective; hooks that return modified data / messages / level / send-decision can act on the prefix value but don't propagate a modified prefix back to downstream hooks. - -## See also - -- The full [release notes for v2](/whats-new) cover every package's bump and any other v2-only changes. -- [`creating-transports.md`](/transports/creating-transports#reading-params-prefix) documents the `params.Prefix` contract for transport authors. -- [`creating-plugins.md`](/plugins/creating-plugins#reading-params-prefix) documents it for plugin authors. +Redirecting to the [Migration Guide](/migrating). diff --git a/docs/src/migrating-to-v3.md b/docs/src/migrating-to-v3.md new file mode 100644 index 0000000..c4c7718 --- /dev/null +++ b/docs/src/migrating-to-v3.md @@ -0,0 +1,9 @@ +--- +title: Migrating to v3 +head: + - - meta + - http-equiv: refresh + content: "0; url=/migrating" +--- + +Redirecting to the [Migration Guide](/migrating). diff --git a/docs/src/migrating.md b/docs/src/migrating.md new file mode 100644 index 0000000..c982dac --- /dev/null +++ b/docs/src/migrating.md @@ -0,0 +1,183 @@ +--- +title: Migration Guide +description: "Upgrade guides for loglayer-go: v2 (import paths /v2, TransportParams.Prefix) and v3 (import paths /v3, MetadataFieldName default)." +--- + +# Migration Guide + +LogLayer for Go has shipped two major versions. Each upgrade is a short checklist; pick the section that matches where you are. + +- **Migrating to v3** (from v2): import paths bump to `/v3`; metadata nests under `"metadata"` by default. +- **Migrating to v2** (from v1): import paths bump to `/v2`; the prefix moves to `TransportParams.Prefix`. + +## Migrating to v3 + +`loglayer-go` v3 ships two changes: **every core import path bumps to `/v3`**, and **metadata now nests under the `"metadata"` key by default** instead of flattening at the root. One config field, `FlattenMetadata: true`, restores the v2 shape. + +This section covers the core (`go.loglayer.dev/v3`); the import-path notes also apply to the core's sub-packages (`/v3/transport`, `/v3/utils/...`). + +::: warning Transports are still on v2 in this release +The transports keep their `v2` paths until the follow-up release that bumps them to `v3`. Until then, code that pairs the v3 core with a `v2` transport path does not compile together. Migrate the core first, then the transports when their v3 bumps land. +::: + +### Do I have to migrate? + +Not immediately. v2.x continues to work; the v2 module path (`go.loglayer.dev/v2`) keeps resolving to its last v2 tag and the v2 metadata placement stays intact there. Future feature work and bug fixes ship at v3 (`go.loglayer.dev/v3`), so the migration is the path forward but it's not on a deadline. + +You can migrate one module at a time: a project that uses several `loglayer-go` sub-modules can have v2 imports for some and v3 for others (Go treats `go.loglayer.dev/v2` and `go.loglayer.dev/v3` as separate modules). The catch is that fields shared between modules (e.g. `loglayer.Config` from main) won't bridge between v2 and v3; pick one core version per project. + +### What changed + +- **Import paths bump to `/v3`** for the core and its sub-packages: `go.loglayer.dev/v2` → `go.loglayer.dev/v3`, `go.loglayer.dev/v2/transport` → `go.loglayer.dev/v3/transport`, and so on. The package import names (`loglayer`, `transport`) do not change. +- **Metadata nests under `"metadata"` by default.** When `Config.MetadataFieldName` is empty, the core resolves it to `"metadata"`, so both map and struct metadata render under that single key uniformly across every transport. This closes the asymmetric v2 gap where renderers flattened map metadata at the root while wrappers nested non-map values under a hardcoded key. It applies to every transport, including third-party ones, because the resolved key ships on `TransportParams.Schema`. +- **`transports/structured` stays on v2 for this release.** The structured transport moves to `/v3` in a follow-up release, along with sanitized output and empty-message handling. The default nesting flip above applies to it today through the schema key. + +### The one-line opt-out + +Set `FlattenMetadata: true` to restore the v2 shape: map metadata merges at the root of the output, and non-map metadata follows each transport's historical placement. The field is ignored when `MetadataFieldName` is explicitly set; an explicit key always wins. + +```go +loglayer.New(loglayer.Config{ + Transport: structured.New(structured.Config{}), + FlattenMetadata: true, +}) +``` + +### Step 1: bump every core import path to `/v3` + +Update your `go.mod` require for the core and your source-file imports. + +```sh +go get go.loglayer.dev/v3 +``` + +In source files: + +```diff + import ( +- "go.loglayer.dev/v2" +- "go.loglayer.dev/v2/transport" ++ "go.loglayer.dev/v3" ++ "go.loglayer.dev/v3/transport" + ) +``` + +Then run `go mod tidy`. Transport and plugin sub-modules keep their current paths until each ships its own v3 bump; check each page in the [Transports overview](/transports/) and [Plugins overview](/plugins/) for the current path. + +### Step 2: decide on metadata placement + +If nothing in your pipeline depends on map metadata living at the root of the JSON output, you're done after the import bump. + +If you do depend on root flattening (a JSON pipeline that parses map metadata at the root of structured / console / pretty output, or an alert rule keyed on a root field), set `FlattenMetadata: true` on the config as a stopgap while you migrate the pipeline, then remove it when the pipeline reads the `"metadata"` key. + +### Step 3: check custom transports and plugins + +- **Custom transports** that read `params.Schema.MetadataFieldName` keep working unchanged: the value is now `"metadata"` instead of `""` unless `FlattenMetadata` is set. Transports that never read the key are unaffected by the default flip. +- **Custom plugins** that inspect `params.Data` to locate metadata should read `params.Schema.MetadataFieldName` rather than assuming a placement. See [Creating Transports → Handling `any` Metadata](/transports/creating-transports#handling-any-metadata) for the placement policies. + +### Known incompatibilities + +Any consumer of the emitted JSON that parsed map metadata at the root now finds it under `"metadata"`. Concretely: + +- Log pipelines and alert rules keyed on root-level fields that were previously metadata. +- Tests asserting on `testing.LogLine` shapes that assumed root flattening. +- Wrapper transports and downstream dashboards that read metadata attributes positionally (the JSON key they appear under changes, not the values). + +All of these are addressed by `FlattenMetadata: true` (v2 shape) or by updating the consumer to read the `"metadata"` key. + +## Migrating to v2 + +`loglayer-go` v2 ships two breaking changes: **every import path bumps to `/v2`**, and **the loglayer core no longer mutates `Messages[0]` to fold the `WithPrefix` value into the message text.** The prefix now flows through `TransportParams.Prefix` and each transport decides how to render it. + +### Do I have to migrate? + +Not immediately. v1.x continues to work; the v1 module path (`go.loglayer.dev`) keeps resolving to its last v1 tag and the auto-prepend behavior stays intact there. Future feature work and bug fixes ship at v2 (`go.loglayer.dev/v2`), so the migration is the path forward but it's not on a deadline. + +You can migrate one module at a time: a project that uses several `loglayer-go` sub-modules can have v1 imports for some and v2 for others (Go treats `go.loglayer.dev` and `go.loglayer.dev/v2` as separate modules). The catch is that fields shared between modules (e.g. `loglayer.Config` from main) won't bridge between v1 and v2; pick one main module per project. + +### Why this change + +`v1.x` folded the prefix into `Messages[0]` from the core so transports that didn't know about prefixes got the right behavior for free. The downside: transports that DID want to render the prefix differently (separate color, separate JSON field, structured forwarding to underlying loggers) couldn't, because by the time they saw the message it was already mangled. Pulling the prefix into a first-class field unblocks every smarter rendering, at the cost of a one-time import-path migration. + +The new contract also keeps the core out of the business of mutating caller-owned input: in v1, the prefix-prepend silently rewrote the user's `Messages` slice before any transport saw it; in v2, the core passes the slice through untouched and exposes the prefix on its own field. + +### Step 1: bump every import path to `/v2` + +The main module and every sub-module are now versioned at `v2`. Update your `go.mod` requires and your source-file imports. + +```sh +# Run for each sub-module you import +go get go.loglayer.dev/v2 +go get go.loglayer.dev/transports/cli/v2 +go get go.loglayer.dev/transports/zerolog/v2 +go get go.loglayer.dev/plugins/redact/v2 +``` + +In source files: + +```diff + import ( +- "go.loglayer.dev" +- "go.loglayer.dev/transports/zerolog" +- "go.loglayer.dev/plugins/redact" ++ "go.loglayer.dev/v2" ++ "go.loglayer.dev/transports/zerolog/v2" ++ "go.loglayer.dev/plugins/redact/v2" + ) +``` + +The package import name (`loglayer`, `zerolog`, `redact`) does not change; only the import path does. + +### Step 2: most users are done + +For users of the built-in transports who don't write custom transports, nothing else changes. Every built-in transport preserves the v1 user-visible output: `log.WithPrefix("[auth]").Info("hi")` still produces `"[auth] hi"` through every renderer / wrapper / network transport, just like it did in v1. + +The exceptions to "nothing else changes": + +- The **cli transport** opts into smart prefix rendering: the user prefix renders in dim grey while the level prefix and message body keep the level color. If you were using cli with `WithPrefix`, the rendered output is now visually layered. See the [cli transport doc](/transports/cli) for an example. +- The **blank transport** hands `params` straight to your `ShipToLogger` function. If your callback was reading the prefix out of `Messages[0]`, read `params.Prefix` instead. +- If you assert on `testing.LogLine` in tests, the unmangled prefix is also available on `LogLine.Prefix` (new field in v2). Existing assertions on `Messages[0]` keep working because the testing transport calls `JoinPrefixAndMessages` internally. + +### Step 3: custom transports + +If you wrote a custom transport that reads `params.Messages[0]` and relied on the prefix being baked in, you have two paths: + +#### Path A: preserve v1 behavior (simplest) + +Call `transport.JoinPrefixAndMessages` at the top of `SendToLogger`: + +```go +import "go.loglayer.dev/v2/transport" + +func (t *Transport) SendToLogger(p loglayer.TransportParams) { + if !t.ShouldProcess(p.LogLevel) { + return + } + p.Messages = transport.JoinPrefixAndMessages(p.Prefix, p.Messages) + // ... your existing rendering logic, unchanged +} +``` + +The helper has fast-path early returns when the prefix is empty, when messages is empty, or when `messages[0]` isn't a string. Per-call cost on a no-prefix logger is one string compare. + +#### Path B: smart rendering + +Read `params.Prefix` directly and render it however suits your transport: + +- A renderer transport could color the prefix differently from the message body (see `transports/cli` for an example). +- A structured / JSON transport could emit the prefix as a separate top-level field instead of embedding it in `msg`. +- A wrapper transport could forward the prefix to the underlying logger's structured-field API (`zerolog.Event.Str("prefix", p.Prefix)`, `zap.Field`, etc.). + +### Step 4: custom plugins + +The dispatch-time plugin hook param structs (`BeforeDataOutParams`, `BeforeMessageOutParams`, `TransformLogLevelParams`, `ShouldSendParams`) gained a `Prefix string` field in v1.7.0; that part is unchanged in v2. The only difference: in v1, `params.Messages[0]` carried the prefix folded in; in v2 it doesn't. Plugins that read the message string directly should be aware. + +The prefix is read-only from the plugin's perspective; hooks that return modified data / messages / level / send-decision can act on the prefix value but don't propagate a modified prefix back to downstream hooks. + +## References + +- [`MetadataFieldName`](/configuration#metadatafieldname) and [`FlattenMetadata`](/configuration#flattenmetadata) in the configuration reference. +- The [Metadata page](/logging-api/metadata) for the v3 nesting rule. +- [`creating-transports.md`](/transports/creating-transports#reading-params-prefix) documents the `params.Prefix` contract for transport authors. +- [`creating-plugins.md`](/plugins/creating-plugins#reading-params-prefix) documents it for plugin authors. +- The full [release notes](/whats-new) cover every package's bump and any other version-specific changes. diff --git a/docs/src/plugins/creating-plugins.md b/docs/src/plugins/creating-plugins.md index bc1619e..4f76e50 100644 --- a/docs/src/plugins/creating-plugins.md +++ b/docs/src/plugins/creating-plugins.md @@ -5,7 +5,7 @@ description: How to write a LogLayer plugin and which hook to reach for. # Creating Plugins -A plugin is anything that satisfies the [`loglayer.Plugin`](https://pkg.go.dev/go.loglayer.dev/v2#Plugin) interface, plus zero or more hook interfaces for the lifecycle points you want to participate in. +A plugin is anything that satisfies the [`loglayer.Plugin`](https://pkg.go.dev/go.loglayer.dev/v3#Plugin) interface, plus zero or more hook interfaces for the lifecycle points you want to participate in. ```go type Plugin interface { @@ -22,7 +22,7 @@ For the registration API see [Plugin Configuration](/plugins/configuration) and **For single-hook inline plugins** use one of the adapter constructors: ```go -import "go.loglayer.dev/v2" +import "go.loglayer.dev/v3" p := loglayer.NewMessageHook("prefix-msg", func(p loglayer.BeforeMessageOutParams) []any { if len(p.Messages) == 0 { @@ -43,7 +43,7 @@ The full set: `NewFieldsHook`, `NewMetadataHook`, `NewDataHook`, `NewMessageHook ```go package mything -import "go.loglayer.dev/v2" +import "go.loglayer.dev/v3" type Plugin struct { id string @@ -407,10 +407,10 @@ Simple, predictable, no reflection. The downside: a struct with a `Password` fie ### Recipe 2: walk every shape (preserve type) -If your plugin needs to walk structs and nested values (recursively, honoring `json` tags), use [`maputil.Cloner`](https://pkg.go.dev/go.loglayer.dev/v2/utils/maputil#Cloner). It produces a deep clone of any value with replacement predicates applied at any depth, preserving the runtime type. +If your plugin needs to walk structs and nested values (recursively, honoring `json` tags), use [`maputil.Cloner`](https://pkg.go.dev/go.loglayer.dev/v3/utils/maputil#Cloner). It produces a deep clone of any value with replacement predicates applied at any depth, preserving the runtime type. ```go -import "go.loglayer.dev/v2/utils/maputil" +import "go.loglayer.dev/v3/utils/maputil" cloner := &maputil.Cloner{ MatchKey: func(k string) bool { return k == "password" || k == "apiKey" }, @@ -429,10 +429,10 @@ The [`plugins/redact`](/plugins/redact) plugin is built on `Cloner`; [its source ### Recipe 3: normalize to a map first -If the **shape** matters more than preserving the user's runtime type, use [`maputil.ToMap`](https://pkg.go.dev/go.loglayer.dev/v2/utils/maputil#ToMap) to JSON-roundtrip the input, then walk the resulting map. +If the **shape** matters more than preserving the user's runtime type, use [`maputil.ToMap`](https://pkg.go.dev/go.loglayer.dev/v3/utils/maputil#ToMap) to JSON-roundtrip the input, then walk the resulting map. ```go -import "go.loglayer.dev/v2/utils/maputil" +import "go.loglayer.dev/v3/utils/maputil" loglayer.NewMetadataHook("normalize", func(metadata any) any { m := maputil.ToMap(metadata) @@ -484,7 +484,7 @@ Every hook call is wrapped in a deferred recover. If your hook panics, the dispa | `LevelHook` | Level unchanged (`ok=false`) | | `SendGate` | Entry sent to the transport (fails open) | -LogLayer writes a one-line description of the recovered panic to `os.Stderr` so the failure isn't silent. To observe the panic in your own code, implement [`ErrorReporter`](https://pkg.go.dev/go.loglayer.dev/v2#ErrorReporter): +LogLayer writes a one-line description of the recovered panic to `os.Stderr` so the failure isn't silent. To observe the panic in your own code, implement [`ErrorReporter`](https://pkg.go.dev/go.loglayer.dev/v3#ErrorReporter): ```go type ErrorReporter interface { diff --git a/docs/src/plugins/datadogtrace.md b/docs/src/plugins/datadogtrace.md index 08f3a4b..ac74e10 100644 --- a/docs/src/plugins/datadogtrace.md +++ b/docs/src/plugins/datadogtrace.md @@ -27,7 +27,7 @@ import ( ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/plugins/datadogtrace/v2" "go.loglayer.dev/transports/structured/v2" ) @@ -176,7 +176,7 @@ The plugin is a no-op for log calls without `WithContext`, so untraced logs pay The plugin ships with a live integration test against the real dd-trace-go v2 tracer (using its in-process `mocktracer`). It validates that the documented v2 extractor pattern produces IDs in the decimal-string format Datadog ingestion expects, including for nested spans. -The livetest lives in **its own Go module** at `plugins/datadogtrace/livetest/` so that dd-trace-go's heavy transitive closure (datadog-agent internals, OTel collector pieces, sketches-go, msgp, ...) stays out of the main `go.loglayer.dev/v2` module. Plugin users get the lean main module; livetest contributors get the full SDK they need. +The livetest lives in **its own Go module** at `plugins/datadogtrace/livetest/` so that dd-trace-go's heavy transitive closure (datadog-agent internals, OTel collector pieces, sketches-go, msgp, ...) stays out of the main `go.loglayer.dev/v3` module. Plugin users get the lean main module; livetest contributors get the full SDK they need. Run it from the repo root: diff --git a/docs/src/plugins/fmtlog.md b/docs/src/plugins/fmtlog.md index 6030add..27148de 100644 --- a/docs/src/plugins/fmtlog.md +++ b/docs/src/plugins/fmtlog.md @@ -13,13 +13,13 @@ description: "Opt-in fmt.Sprintf semantics for multi-arg log calls." go get go.loglayer.dev/plugins/fmtlog/v2 ``` -`fmtlog` is its own Go module under `go.loglayer.dev/plugins/fmtlog/v2`, with no third-party dependencies beyond the main `go.loglayer.dev/v2` module it implements `Plugin` against. +`fmtlog` is its own Go module under `go.loglayer.dev/plugins/fmtlog/v2`, with no third-party dependencies beyond the main `go.loglayer.dev/v3` module it implements `Plugin` against. ## Basic Usage ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/plugins/fmtlog/v2" "go.loglayer.dev/transports/structured/v2" ) diff --git a/docs/src/plugins/oteltrace.md b/docs/src/plugins/oteltrace.md index 7476178..5d4f7cb 100644 --- a/docs/src/plugins/oteltrace.md +++ b/docs/src/plugins/oteltrace.md @@ -14,7 +14,7 @@ go get go.loglayer.dev/plugins/oteltrace/v2 ``` ::: info Separate module -`plugins/oteltrace` ships as its own Go module (`go.loglayer.dev/plugins/oteltrace/v2`) so the OpenTelemetry API's Go-version requirement doesn't bind the main `go.loglayer.dev/v2` module. Requires **Go 1.25+** because that's the floor of `go.opentelemetry.io/otel/trace` and `go.opentelemetry.io/otel/baggage` at current versions. +`plugins/oteltrace` ships as its own Go module (`go.loglayer.dev/plugins/oteltrace/v2`) so the OpenTelemetry API's Go-version requirement doesn't bind the main `go.loglayer.dev/v3` module. Requires **Go 1.25+** because that's the floor of `go.opentelemetry.io/otel/trace` and `go.opentelemetry.io/otel/baggage` at current versions. ::: ::: info When to use this vs `transports/otellog` @@ -27,7 +27,7 @@ go get go.loglayer.dev/plugins/oteltrace/v2 ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/plugins/oteltrace/v2" "go.loglayer.dev/transports/structured/v2" ) diff --git a/docs/src/plugins/redact.md b/docs/src/plugins/redact.md index 9783aa9..364589f 100644 --- a/docs/src/plugins/redact.md +++ b/docs/src/plugins/redact.md @@ -19,7 +19,7 @@ Dependency-free. Pure Go (only `regexp` from stdlib; the walker uses reflection ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/plugins/redact/v2" "go.loglayer.dev/transports/structured/v2" ) diff --git a/docs/src/plugins/sampling.md b/docs/src/plugins/sampling.md index e0e9845..58a6363 100644 --- a/docs/src/plugins/sampling.md +++ b/docs/src/plugins/sampling.md @@ -23,7 +23,7 @@ Pure Go, no dependencies (uses `math/rand/v2` from the stdlib). ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/plugins/sampling/v2" "go.loglayer.dev/transports/structured/v2" ) diff --git a/docs/src/plugins/testing-plugins.md b/docs/src/plugins/testing-plugins.md index 1472fab..6077666 100644 --- a/docs/src/plugins/testing-plugins.md +++ b/docs/src/plugins/testing-plugins.md @@ -13,7 +13,7 @@ description: Helpers for testing custom LogLayer plugin implementations. import ( "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/plugins/plugintest/v2" ) @@ -47,7 +47,7 @@ plugintest.AssertNoMutation[any](t, ## Verifying panic recovery -LogLayer recovers hook panics and forwards them to a plugin's `OnError` (when the plugin implements [`loglayer.ErrorReporter`](https://pkg.go.dev/go.loglayer.dev/v2#ErrorReporter)). Use `plugintest.AssertPanicRecovered` to drive a panicking hook and assert that a `*loglayer.RecoveredPanicError` was forwarded. +LogLayer recovers hook panics and forwards them to a plugin's `OnError` (when the plugin implements [`loglayer.ErrorReporter`](https://pkg.go.dev/go.loglayer.dev/v3#ErrorReporter)). Use `plugintest.AssertPanicRecovered` to drive a panicking hook and assert that a `*loglayer.RecoveredPanicError` was forwarded. The helper takes a builder closure that receives a `captureFn`: thread it through to your plugin's `OnError` so the recovery path delivers the panic to the helper's capture. diff --git a/docs/src/public/llms-full.txt b/docs/src/public/llms-full.txt index 826204d..1465a49 100644 --- a/docs/src/public/llms-full.txt +++ b/docs/src/public/llms-full.txt @@ -1,13 +1,13 @@ # LogLayer for Go: Comprehensive LLM Reference -> Transport-agnostic structured logging for Go. A fluent API on top of zerolog, zap, logrus, phuslu/log, charmbracelet/log, log/slog, OpenTelemetry, or any custom transport. Module path: `go.loglayer.dev/v2`. GitHub: `github.com/loglayer/loglayer-go`. +> Transport-agnostic structured logging for Go. A fluent API on top of zerolog, zap, logrus, phuslu/log, charmbracelet/log, log/slog, OpenTelemetry, or any custom transport. Module path: `go.loglayer.dev/v3`. GitHub: `github.com/loglayer/loglayer-go`. This is the comprehensive reference. For the concise index see [llms.txt](https://go.loglayer.dev/llms.txt). ## Installation ```sh -go get go.loglayer.dev/v2 +go get go.loglayer.dev/v3 ``` LogLayer is multi-module: most transports, plugins, and integrations ship as their own Go modules. Install only what you import. @@ -63,7 +63,7 @@ package main import ( "errors" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/structured/v2" ) @@ -121,18 +121,20 @@ log.Info("user", 123, "logged in") ## Metadata (per-message structured data) ```go -// Map metadata flattens to root keys +// Map metadata nests under the "metadata" key (MetadataFieldName default) log.WithMetadata(loglayer.Metadata{"userId": "123", "action": "login"}).Info("user logged in") // loglayer.M is a shorter alias log.WithMetadata(loglayer.M{"durationMs": 42}).Info("served") -// Struct metadata: its fields merge at the root (JSON-roundtripped) +// Struct metadata: JSON-roundtripped and nested under the "metadata" key type Event struct { OrderID string `json:"orderId"` Path string `json:"path"` } log.WithMetadata(Event{OrderID: "o-1", Path: "/checkout"}).Info("event") + +// v2 shape opt-out: Config.FlattenMetadata: true restores root-level merge ``` ### Metadata-Only Logging @@ -149,7 +151,7 @@ log.MetadataOnly(loglayer.M{"queueDepth": 17}, loglayer.MetadataOnlyOpts{ ### Nested Metadata Field -`loglayer.Config.MetadataFieldName` (a single core knob) nests both map and non-map metadata under one configurable key uniformly across every transport. When unset, each transport keeps its default policy: renderers flatten map metadata at root, wrappers flatten map metadata as individual attributes and nest non-map values under a hardcoded `"metadata"` key. +`loglayer.Config.MetadataFieldName` (a single core knob) nests both map and non-map metadata under one configurable key uniformly across every transport. In v3 the default is `"metadata"`: when `MetadataFieldName` is empty, the core resolves it to `"metadata"` for every transport and publishes the resolved key on `loglayer.Schema`. Set `Config.FlattenMetadata: true` to restore the v2 shape: map metadata merges at the root, and non-map metadata follows each transport's historical placement. `FlattenMetadata` is ignored when `MetadataFieldName` is explicitly set. ```go loglayer.New(loglayer.Config{ @@ -351,9 +353,11 @@ log := loglayer.New(loglayer.Config{ CopyMsgOnOnlyError: false, // Field/metadata layout - FieldsKey: "", // empty = merge at root - MuteFields: false, - MuteMetadata: false, + FieldsKey: "", // empty = merge at root + MetadataFieldName: "", // default "metadata": nest metadata under this key + FlattenMetadata: false, // v2 shape opt-out: flatten map metadata at root when MetadataFieldName is unset + MuteFields: false, + MuteMetadata: false, // Fatal behavior DisableFatalExit: false, // false = Fatal calls os.Exit(1) @@ -617,7 +621,7 @@ type BaseConfig struct { Pattern across all transports: ```go -import "go.loglayer.dev/v2/transport" +import "go.loglayer.dev/v3/transport" structured.New(structured.Config{ BaseConfig: transport.BaseConfig{ID: "main", Level: loglayer.LogLevelInfo}, @@ -1164,7 +1168,7 @@ require.Equal(t, "[REDACTED]", md["pw"]) ## Multi-Module Versioning -`go.loglayer.dev/v2` is the main module; every transport, plugin, and integration ships as its own Go module. Tags use the prefix form (`transports//v`, `plugins//v`). A breaking change in any one sub-module bumps only that sub-module's major version, so `go.loglayer.dev/v2`'s import path stays stable. +`go.loglayer.dev/v3` is the main module; every transport, plugin, and integration ships as its own Go module. Tags use the prefix form (`transports//v`, `plugins//v`). A breaking change in any one sub-module bumps only that sub-module's major version, so `go.loglayer.dev/v3`'s import path stays stable. Full module list: [`monorel.toml`](https://github.com/loglayer/loglayer-go/blob/main/monorel.toml). diff --git a/docs/src/public/llms.txt b/docs/src/public/llms.txt index fd899fe..7d0a009 100644 --- a/docs/src/public/llms.txt +++ b/docs/src/public/llms.txt @@ -1,11 +1,11 @@ # LogLayer for Go -> Transport-agnostic structured logging for Go. A fluent API on top of zerolog, zap, logrus, phuslu/log, charmbracelet/log, log/slog, OpenTelemetry, or any custom transport. Module path: `go.loglayer.dev/v2`. GitHub: `github.com/loglayer/loglayer-go`. +> Transport-agnostic structured logging for Go. A fluent API on top of zerolog, zap, logrus, phuslu/log, charmbracelet/log, log/slog, OpenTelemetry, or any custom transport. Module path: `go.loglayer.dev/v3`. GitHub: `github.com/loglayer/loglayer-go`. ## Installation ```sh -go get go.loglayer.dev/v2 +go get go.loglayer.dev/v3 ``` Most transports and plugins ship as their own modules. Install only what you import: @@ -22,7 +22,7 @@ go get go.loglayer.dev/plugins/redact/v2 package main import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/structured/v2" ) @@ -57,13 +57,15 @@ log.Info("user", 123, "logged in") Data attached to a single log entry only. ```go -// Map-style metadata flattens to root keys +// Map-style metadata nests under the "metadata" key (MetadataFieldName default) log.WithMetadata(loglayer.Metadata{"userId": "123", "action": "login"}).Info("user logged in") +// v2 shape opt-out: Config.FlattenMetadata: true restores root-level merge + // loglayer.M is a shorter alias for loglayer.Metadata log.WithMetadata(loglayer.M{"durationMs": 42}).Info("served") -// Struct metadata: its fields merge at the root (JSON-roundtripped) +// Struct metadata: JSON-roundtripped and nested under the "metadata" key type Event struct { OrderID string `json:"orderId"` Path string `json:"path"` @@ -177,6 +179,8 @@ log := loglayer.New(loglayer.Config{ FieldsKey: "", // empty = merge fields at root; set to nest under a key MuteFields: false, MuteMetadata: false, + MetadataFieldName: "", // default "metadata": nest metadata under this key + FlattenMetadata: false, // v2 shape opt-out: flatten map metadata at root DisableFatalExit: false, // false = Fatal calls os.Exit(1), matching Go convention // Source / caller info (off by default; ~620 ns + 5 allocs per emission when on) @@ -411,7 +415,7 @@ lines := lib.Lines() // []lltest.LogLine; assert on Level, Messages, Data, Meta - [Mocking](https://go.loglayer.dev/logging-api/mocking): `loglayer.NewMock()` and `transports/testing` - [Transport Overview](https://go.loglayer.dev/transports/): All transports - [Plugins Overview](https://go.loglayer.dev/plugins/): Plugin system and hooks -- [For TypeScript Developers](https://go.loglayer.dev/for-typescript-developers): API mapping from `loglayer` (TS) to `go.loglayer.dev/v2` +- [For TypeScript Developers](https://go.loglayer.dev/for-typescript-developers): API mapping from `loglayer` (TS) to `go.loglayer.dev/v3` ## Optional diff --git a/docs/src/transports/_partials/metadata-field-name.md b/docs/src/transports/_partials/metadata-field-name.md index e46bb84..4a3bce7 100644 --- a/docs/src/transports/_partials/metadata-field-name.md +++ b/docs/src/transports/_partials/metadata-field-name.md @@ -1 +1 @@ -The placement key for non-map metadata is controlled by the core via [`MetadataFieldName`](/configuration#metadatafieldname). When unset, this transport defaults to nesting non-map metadata under `"metadata"`. +The core nests the entry's metadata under [`MetadataFieldName`](/configuration#metadatafieldname) (default `"metadata"`; set `Config.FlattenMetadata: true` to restore per-transport v2 placement). This transport honors that key. diff --git a/docs/src/transports/axiom.md b/docs/src/transports/axiom.md index ed54d42..3c6f3ed 100644 --- a/docs/src/transports/axiom.md +++ b/docs/src/transports/axiom.md @@ -31,7 +31,7 @@ The dataset is set on the transport via `Config.DatasetName`, not an env var. ```go import ( axiomgo "github.com/axiomhq/axiom-go/axiom" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/axiom/v2" ) @@ -55,7 +55,7 @@ log := loglayer.New(loglayer.Config{ ```go import ( axiomgo "github.com/axiomhq/axiom-go/axiom" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/axiom/v2" ) @@ -104,7 +104,7 @@ Each log entry is ingested as a JSON object: - `msg`: the joined message text (configurable via `MessageField`) - Persistent fields from `WithFields()`, merged at root - The serialized error from `WithError()` -- Map metadata flattened at root, or any other metadata nested under `metadata` +- Metadata nested under the core's `MetadataFieldName` key (default `"metadata"`; map metadata flattens at root only when the core runs with `FlattenMetadata: true`, the v2 shape) ```go log.WithFields(loglayer.Fields{"requestId": "abc"}). @@ -120,13 +120,17 @@ results in: "msg": "served", "requestId": "abc", "err": { "message": "timeout" }, - "durationMs": 42 + "metadata": { "durationMs": 42 } } ``` +## Fatal Behavior + +The transport never calls `os.Exit` or `panic` itself. The Axiom SDK is called synchronously per entry (`Client.Ingest`), so the fatal entry reaches Axiom before the log call returns. Whether the process terminates afterward is the LogLayer core's decision via `Config.DisableFatalExit` (default: exit). See [Fatal Exits the Process](/logging-api/basic-logging#fatal-exits-the-process). + ## Metadata Handling -Map metadata (`loglayer.Metadata`) merges at the root of the JSON object. Non-map metadata (structs, scalars) nests under the `metadata` key by default. +Metadata follows the [core placement rules](/configuration#metadatafieldname): when `Config.MetadataFieldName` is empty, the core resolves it to `"metadata"` and the whole metadata value (map or non-map) nests under that key; with `Config.FlattenMetadata: true` (the v2 opt-out), map metadata merges at the root and non-map metadata nests under `metadata`. Set [`Config.MetadataFieldName`](/configuration#metadatafieldname) on the core to nest all metadata under a fixed key. diff --git a/docs/src/transports/betterstack.md b/docs/src/transports/betterstack.md index f823e3c..3a3e0bf 100644 --- a/docs/src/transports/betterstack.md +++ b/docs/src/transports/betterstack.md @@ -9,6 +9,10 @@ description: Ship logs to Better Stack's HTTP intake API. Sends log entries to [Better Stack](https://betterstack.com) via their HTTP Logs Endpoint. Built on the [HTTP transport](/transports/http) with a Better Stack-specific encoder and bearer token authentication. +::: info Interim state: this transport is still on v2 +This transport keeps its `v2` path and its pre-v3 metadata placement for this release; the placement described below is what the current `v2` transport produces. The v3 core resolves an empty `MetadataFieldName` to `"metadata"`, so pairing this transport with the v3 core passes a non-empty schema key and changes the placement. The transport's own v3 bump ships in a follow-up release. +::: + ```sh go get go.loglayer.dev/transports/betterstack ``` @@ -31,7 +35,7 @@ The source token is a secret. Load it from an environment variable or secret man ```go import ( "os" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/betterstack" ) diff --git a/docs/src/transports/blank.md b/docs/src/transports/blank.md index 19c8053..00d1352 100644 --- a/docs/src/transports/blank.md +++ b/docs/src/transports/blank.md @@ -23,8 +23,8 @@ go get go.loglayer.dev/transports/blank/v2 ```go import ( - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" "go.loglayer.dev/transports/blank/v2" ) diff --git a/docs/src/transports/charmlog.md b/docs/src/transports/charmlog.md index ecf0fff..9039e87 100644 --- a/docs/src/transports/charmlog.md +++ b/docs/src/transports/charmlog.md @@ -24,7 +24,7 @@ import ( clog "github.com/charmbracelet/log" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" llcharm "go.loglayer.dev/transports/charmlog/v2" ) diff --git a/docs/src/transports/cli.md b/docs/src/transports/cli.md index 940120d..d84cfd0 100644 --- a/docs/src/transports/cli.md +++ b/docs/src/transports/cli.md @@ -24,7 +24,7 @@ go get go.loglayer.dev/transports/cli/v2 ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" cli "go.loglayer.dev/transports/cli/v2" ) @@ -169,7 +169,7 @@ The standard CLI shape is to wire `-v` flags to loglayer's level state and `-vv` ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" cli "go.loglayer.dev/transports/cli/v2" ) @@ -228,8 +228,8 @@ Use it when the default `[level prefix][user prefix][message] [fields]` layout d import ( "fmt" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" cli "go.loglayer.dev/transports/cli/v2" ) @@ -287,7 +287,7 @@ For machine-readable output, swap `cli` for [structured](/transports/structured) ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" cli "go.loglayer.dev/transports/cli/v2" "go.loglayer.dev/transports/structured/v2" ) diff --git a/docs/src/transports/configuration.md b/docs/src/transports/configuration.md index c8c3124..8e6e7f8 100644 --- a/docs/src/transports/configuration.md +++ b/docs/src/transports/configuration.md @@ -51,19 +51,25 @@ console.New(console.Config{ ## Transport IDs -`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. +Every transport has an `ID()` method; set the ID at construction via `transport.BaseConfig{ID: ...}`. Every transport's `New` returns a `*Transport` wrapping its `BaseTransport`, so when the ID is empty the constructed value carries the auto-generated ID (`auto-transport-`) and you can read it back from `ID()` before wiring the transport into the logger. If you later call `RemoveTransport(id)` or `GetLoggerInstance(id)` with the wrong string, the call silently under-delivers: `RemoveTransport` returns `false`, `GetLoggerInstance` returns `nil`, and the transport stays in the dispatch list. Confirm the assigned ID with `tr.ID()` before relying on it. Keep the constructed transport handle: `*LogLayer` offers no ID-lookup method, so once the transport is handed to the config, its ID is unrecoverable from the logger. The base config lives in the shared `transport` package. Import it alongside the transport: ```go -import "go.loglayer.dev/v2/transport" +import "go.loglayer.dev/v3/transport" console.New(console.Config{ BaseConfig: transport.BaseConfig{ID: "console"}, }) ``` -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. +An ID is only needed when the logger will manage that transport at runtime: `RemoveTransport(id)`, `GetLoggerInstance(id)`, and replace-by-ID (`AddTransport` replaces an existing transport with the same ID instead of duplicating it). 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. + +The random-ID hazard applies most in fan-out setups: `RemoveTransport("ship")` removes nothing (and returns `false`) when the "ship" transport was constructed without an ID and got `auto-transport-...`. See [Multiple Transports → Transport IDs](/transports/multiple-transports#transport-ids) for a worked example. + +::: warning Auto-generated IDs are random +Leaving `BaseConfig.ID` empty assigns a random ID per construction. Never call `RemoveTransport` or `GetLoggerInstance` with an ID you copied from an earlier run, and do not key routing config off an auto-generated ID: it changes every process start. Always set explicit IDs for any transport you intend to manage by ID. +::: ## Enabling and disabling per environment diff --git a/docs/src/transports/console.md b/docs/src/transports/console.md index 8866765..a8b0b99 100644 --- a/docs/src/transports/console.md +++ b/docs/src/transports/console.md @@ -23,7 +23,7 @@ go get go.loglayer.dev/transports/console/v2 ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/console/v2" ) diff --git a/docs/src/transports/creating-transports.md b/docs/src/transports/creating-transports.md index 5512b55..d14873c 100644 --- a/docs/src/transports/creating-transports.md +++ b/docs/src/transports/creating-transports.md @@ -27,8 +27,8 @@ import ( "fmt" "io" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" ) type Config struct { @@ -284,8 +284,8 @@ If `New` can fail with a runtime-loaded value (URL from env, API key from secret package yourpkg import ( - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" ) type Config struct { diff --git a/docs/src/transports/datadog.md b/docs/src/transports/datadog.md index 6348194..8331b95 100644 --- a/docs/src/transports/datadog.md +++ b/docs/src/transports/datadog.md @@ -9,6 +9,10 @@ description: Ship logs to the Datadog Logs HTTP intake API. Sends log entries to Datadog's [Logs HTTP intake API](https://docs.datadoghq.com/api/latest/logs/#send-logs). Built on the [HTTP transport](/transports/http) with a Datadog-specific encoder, site-aware URL, and `DD-API-KEY` header. +::: info Interim state: this transport is still on v2 +This transport keeps its `v2` path and its pre-v3 metadata placement for this release; the placement described below is what the current `v2` transport produces. The v3 core resolves an empty `MetadataFieldName` to `"metadata"`, so pairing this transport with the v3 core passes a non-empty schema key and changes the placement. The transport's own v3 bump ships in a follow-up release. +::: + ```sh go get go.loglayer.dev/transports/datadog/v2 ``` @@ -43,7 +47,7 @@ The API key is a secret. Treat it like a password: load it from an environment v import ( "os" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/datadog/v2" ) diff --git a/docs/src/transports/gcplogging.md b/docs/src/transports/gcplogging.md index eab1f55..fa7a717 100644 --- a/docs/src/transports/gcplogging.md +++ b/docs/src/transports/gcplogging.md @@ -66,7 +66,7 @@ import ( "cloud.google.com/go/logging" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/gcplogging/v2" ) diff --git a/docs/src/transports/http.md b/docs/src/transports/http.md index d490474..739f793 100644 --- a/docs/src/transports/http.md +++ b/docs/src/transports/http.md @@ -9,6 +9,10 @@ description: Generic batched HTTP POST transport with a pluggable encoder. The `http` transport ships log entries to an HTTP endpoint as JSON in async batches. Use it directly to talk to any log-ingestion API, or as the foundation for a service-specific wrapper (the [Datadog transport](/transports/datadog) is built on it). +::: info Interim state: this transport is still on v2 +This transport keeps its `v2` path and its pre-v3 metadata placement for this release; the placement described below is what the current `v2` transport produces. The v3 core resolves an empty `MetadataFieldName` to `"metadata"`, so pairing this transport with the v3 core passes a non-empty schema key and changes the placement. The transport's own v3 bump ships in a follow-up release. +::: + ```sh go get go.loglayer.dev/transports/http/v2 ``` @@ -21,7 +25,7 @@ The directory is `transports/http`; the package name is `httptransport` to avoid import ( "os" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" httptr "go.loglayer.dev/transports/http/v2" ) diff --git a/docs/src/transports/logrus.md b/docs/src/transports/logrus.md index 95252a9..6c24930 100644 --- a/docs/src/transports/logrus.md +++ b/docs/src/transports/logrus.md @@ -22,7 +22,7 @@ import ( "github.com/sirupsen/logrus" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" lllogrus "go.loglayer.dev/transports/logrus/v2" ) diff --git a/docs/src/transports/lumberjack.md b/docs/src/transports/lumberjack.md index 3151fb0..ee6e11b 100644 --- a/docs/src/transports/lumberjack.md +++ b/docs/src/transports/lumberjack.md @@ -17,7 +17,7 @@ go get go.loglayer.dev/transports/lumberjack/v2 ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/lumberjack/v2" ) @@ -179,8 +179,8 @@ If you only have the `*loglayer.LogLayer` (e.g. inside a handler that received t import ( lj "gopkg.in/natefinch/lumberjack.v2" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" "go.loglayer.dev/transports/lumberjack/v2" ) @@ -206,7 +206,7 @@ Render colorized output to the terminal during interactive runs (via the [Pretty ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/lumberjack/v2" "go.loglayer.dev/transports/pretty/v2" ) @@ -234,8 +234,8 @@ A common ops pattern: ship everything to `info.log` and ship errors-only to a sm ```go import ( - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" "go.loglayer.dev/transports/lumberjack/v2" ) diff --git a/docs/src/transports/management.md b/docs/src/transports/management.md index 6ef6151..87e1b0d 100644 --- a/docs/src/transports/management.md +++ b/docs/src/transports/management.md @@ -14,7 +14,7 @@ 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" +import "go.loglayer.dev/v3/transport" log.AddTransport(structured.New(structured.Config{ BaseConfig: transport.BaseConfig{ID: "ship"}, diff --git a/docs/src/transports/multiple-transports.md b/docs/src/transports/multiple-transports.md index ba0b29c..80e350c 100644 --- a/docs/src/transports/multiple-transports.md +++ b/docs/src/transports/multiple-transports.md @@ -60,6 +60,40 @@ log.Info("local-only") // console only log.Warn("everywhere") // both ``` +## Transport IDs + +Every transport has an `ID()` method; set it at construction via `transport.BaseConfig{ID: ...}`. When empty, a random ID is assigned, so `RemoveTransport` / `GetLoggerInstance` by ID silently under-delivers: `RemoveTransport` returns `false`, `GetLoggerInstance` returns `nil`, and the transport stays in the dispatch list. Confirm the assigned ID with the constructed transport's `ID()` before relying on it. Keep the constructed transport handle: `*LogLayer` offers no ID-lookup method, so once the transport is handed to the config, its ID is unrecoverable from the logger. + +The example below sets `ID: "ship"` at construction, then removes that transport at runtime with the same string: + +```go +import ( + "os" + + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" + "go.loglayer.dev/transports/structured/v2" +) + +logFile, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) +if err != nil { + panic(err) +} + +log := loglayer.New(loglayer.Config{ + Transports: []loglayer.Transport{ + structured.New(structured.Config{ + BaseConfig: transport.BaseConfig{ID: "ship"}, + Writer: logFile, + }), + }, +}) + +removed := log.RemoveTransport("ship") // true: the ID matches construction +``` + +For a transport constructed without `BaseConfig.ID`, reading `ID()` off the constructed value (before wiring it into the logger) is the way to confirm the auto-generated ID. Transports keep their IDs when passed to `AddTransport` / `SetTransports` by value or pointer. + ## Adding and Removing at Runtime ```go @@ -87,8 +121,8 @@ A realistic production setup. Pretty is colorized terminal output for the develo import ( "os" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" "go.loglayer.dev/transports/datadog/v2" "go.loglayer.dev/transports/pretty/v2" "go.loglayer.dev/transports/structured/v2" diff --git a/docs/src/transports/newrelic.md b/docs/src/transports/newrelic.md index 6648ae9..63dd0f4 100644 --- a/docs/src/transports/newrelic.md +++ b/docs/src/transports/newrelic.md @@ -42,7 +42,7 @@ The license key is a secret. Treat it like a password: load it from an environme import ( "os" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/newrelic" ) diff --git a/docs/src/transports/otellog.md b/docs/src/transports/otellog.md index edfc758..7fd7983 100644 --- a/docs/src/transports/otellog.md +++ b/docs/src/transports/otellog.md @@ -27,7 +27,7 @@ If your app has already registered an OTel `LoggerProvider` globally (the common ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/otellog/v2" ) diff --git a/docs/src/transports/phuslu.md b/docs/src/transports/phuslu.md index 4b0e626..d6e5802 100644 --- a/docs/src/transports/phuslu.md +++ b/docs/src/transports/phuslu.md @@ -22,7 +22,7 @@ import ( plog "github.com/phuslu/log" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" llphuslu "go.loglayer.dev/transports/phuslu/v2" ) diff --git a/docs/src/transports/pretty.md b/docs/src/transports/pretty.md index 38e48a0..c9400ed 100644 --- a/docs/src/transports/pretty.md +++ b/docs/src/transports/pretty.md @@ -21,7 +21,7 @@ This transport pulls in `github.com/fatih/color` for ANSI handling. ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/pretty/v2" ) @@ -214,6 +214,10 @@ Doesn't affect expanded mode; it always shows the full tree. ## Metadata Handling +::: info Interim state: this transport is still on v2 +This transport keeps its `v2` path and its pre-v3 metadata placement for this release; the shape below is what the current `v2` transport produces. The v3 core resolves an empty `MetadataFieldName` to `"metadata"`, so pairing this transport with the v3 core passes a non-empty schema key and changes the placement. The transport's own v3 bump ships in a follow-up release. +::: + When [`MetadataFieldName`](/configuration#metadatafieldname) is empty (the default), the breakdown by metadata shape is: - **Maps** merge at the root, alongside fields and error fields. diff --git a/docs/src/transports/sentry.md b/docs/src/transports/sentry.md index 970fb8e..aade5c4 100644 --- a/docs/src/transports/sentry.md +++ b/docs/src/transports/sentry.md @@ -39,7 +39,7 @@ import ( "github.com/getsentry/sentry-go" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" sentrytransport "go.loglayer.dev/transports/sentry/v2" ) diff --git a/docs/src/transports/slog.md b/docs/src/transports/slog.md index c8bd117..f35b408 100644 --- a/docs/src/transports/slog.md +++ b/docs/src/transports/slog.md @@ -20,7 +20,7 @@ import ( "log/slog" "os" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" llslog "go.loglayer.dev/transports/slog/v2" ) diff --git a/docs/src/transports/structured.md b/docs/src/transports/structured.md index 0baba01..2990a31 100644 --- a/docs/src/transports/structured.md +++ b/docs/src/transports/structured.md @@ -7,7 +7,7 @@ description: One JSON object per log entry. The default for production logging. -The `structured` transport always writes one JSON object per log entry. By default each entry has `level`, `time`, and `msg` fields, with fields and metadata merged at the root. +The `structured` transport always writes one JSON object per log entry. By default each entry has `level`, `time`, and `msg` fields, with fields merged at the root. ```sh go get go.loglayer.dev/transports/structured/v2 @@ -17,7 +17,7 @@ go get go.loglayer.dev/transports/structured/v2 ```go import ( - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/structured/v2" ) diff --git a/docs/src/transports/testing-transports.md b/docs/src/transports/testing-transports.md index ef3148a..5c52cae 100644 --- a/docs/src/transports/testing-transports.md +++ b/docs/src/transports/testing-transports.md @@ -16,9 +16,9 @@ import ( "bytes" "testing" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" - "go.loglayer.dev/v2/transport/transporttest" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" + "go.loglayer.dev/v3/transport/transporttest" ) func TestMyTransport_Basic(t *testing.T) { @@ -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 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): +`transport/transporttest` ships a [`RunContract`](https://pkg.go.dev/go.loglayer.dev/v3/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/writers.md b/docs/src/transports/writers.md index ee1f56a..8039793 100644 --- a/docs/src/transports/writers.md +++ b/docs/src/transports/writers.md @@ -31,7 +31,7 @@ log := loglayer.New(loglayer.Config{ ```go import ( "os" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/structured/v2" ) @@ -56,7 +56,7 @@ The dedicated [lumberjack](/transports/lumberjack) transport already does this f ```go import ( "gopkg.in/natefinch/lumberjack.v2" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/pretty/v2" ) @@ -103,7 +103,7 @@ A `*bytes.Buffer` works but isn't safe for concurrent writes. For real test asse ```go import ( "bytes" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/structured/v2" ) @@ -124,7 +124,7 @@ Anything that satisfies `io.Writer` works, including `net.Conn`. The example bel ```go import ( "net" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" "go.loglayer.dev/transports/structured/v2" ) @@ -201,7 +201,7 @@ Wrapper transports ([Zerolog](/transports/zerolog), [Zap](/transports/zap), [log ```go import ( "github.com/rs/zerolog" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" llzero "go.loglayer.dev/transports/zerolog/v2" ) diff --git a/docs/src/transports/zap.md b/docs/src/transports/zap.md index f00a5ad..362fd8f 100644 --- a/docs/src/transports/zap.md +++ b/docs/src/transports/zap.md @@ -20,7 +20,7 @@ go get go.uber.org/zap import ( "go.uber.org/zap" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" llzap "go.loglayer.dev/transports/zap/v2" ) diff --git a/docs/src/transports/zerolog.md b/docs/src/transports/zerolog.md index 49ee1c9..5da90ae 100644 --- a/docs/src/transports/zerolog.md +++ b/docs/src/transports/zerolog.md @@ -21,7 +21,7 @@ import ( zlog "github.com/rs/zerolog" "os" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" llzero "go.loglayer.dev/transports/zerolog/v2" ) diff --git a/docs/src/whats-new.md b/docs/src/whats-new.md index 2034ead..287fe82 100644 --- a/docs/src/whats-new.md +++ b/docs/src/whats-new.md @@ -7,6 +7,14 @@ 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 21, 2026 + +`v3.0.0`: + +**Metadata now nests by default.** The core is now `go.loglayer.dev/v3` (with `/v3/transport` and `/v3/utils/*` sub-packages), and `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`. See [Migrating to v3](/migrating#migrating-to-v3). + +`transports/structured` stays on `go.loglayer.dev/transports/structured/v2` for this release; its own v3 bump ships in a follow-up. + ## Aug 19, 2026 `loglayer`: @@ -83,7 +91,7 @@ Republished every module with a clean `go.mod`. The v2.0.0 cascade shipped sub-m `v2.0.0`: -**Breaking: import paths bump to `/v2`.** The loglayer core no longer mutates `Messages[0]` to fold the `WithPrefix` value into the message text. The prefix flows through `TransportParams.Prefix` and each transport decides how to render it. Built-in transports preserve v1 user-visible output via the new `transport.JoinPrefixAndMessages` helper; the cli transport opts into smart rendering (dim-grey user prefix separate from level color). See [Migrating to v2](/migrating-to-v2) for the upgrade checklist. +**Breaking: import paths bump to `/v2`.** The loglayer core no longer mutates `Messages[0]` to fold the `WithPrefix` value into the message text. The prefix flows through `TransportParams.Prefix` and each transport decides how to render it. Built-in transports preserve v1 user-visible output via the new `transport.JoinPrefixAndMessages` helper; the cli transport opts into smart rendering (dim-grey user prefix separate from level color). See [Migrating to v2](/migrating#migrating-to-v2) for the upgrade checklist. `loglayer`: diff --git a/docs/superpowers/specs/2026-08-21-dx-v3-metadata-and-docs-design.md b/docs/superpowers/specs/2026-08-21-dx-v3-metadata-and-docs-design.md new file mode 100644 index 0000000..0c9339c --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-dx-v3-metadata-and-docs-design.md @@ -0,0 +1,200 @@ +# DX v3: uniform metadata nesting + docs sweep + +Date: 2026-08-21 +Status: approved design (brainstorming session) + +## Problem + +Feedback from the hmn-cli migration (LOGLAYER.md, four open concerns) plus two +code gaps found during design: + +1. **#7 Metadata shape depends on the value type and the transport class.** + With `Config.MetadataFieldName` empty (the v2 default), map metadata flattens + to root attributes in every transport, while non-map metadata (structs, + scalars) nests under a hardcoded `"metadata"` key in wrapper transports and + JSON-roundtrips to root in renderers. Same `WithMetadata` call, different + JSON shape depending on what type you pass and which transport renders it. +2. **ANSI gap.** `transports/structured` writes message and metadata values + through `encoding/json` directly, which escapes C0 controls (ESC becomes + ``, CR/LF escaped) but passes bidi and zero-width characters through + raw. The terminal renderers (console/pretty/cli) sanitize those; structured + does not. +3. **#2 New-vs-Build discoverability.** `Build` is the right constructor for + runtime config, but the docs present `New` as primary and `Build` as an + afterthought (the hmn complaint). +4. **#3 Fatal exit foot-gun.** Default stays `os.Exit(1)` (matches + log.Fatal/zerolog/zap). Docs need to make the `DisableFatalExit` escape + hatch and the shutdown-flush behavior prominent and discoverable. +5. **#6 Transport IDs discoverability.** Setting an ID requires + `transport.BaseConfig{ID: ...}` plus an import; nothing on the + `*LogLayer` API or the transport pages makes that discoverable before + management methods (`AddTransport` / `RemoveTransport`) silently misbehave. + +Decisions from the brainstorming session: + +- Ship the metadata default flip as a **v3 core** (`go.loglayer.dev/v3`). +- Keep the `Fatal` exit default; harden docs only. +- No new `KV()` method; `MetadataOnly` is the KV idiom; docs say so. +- **#5 F/M aliases and #10 empty-msg/KV are out of scope** for this design. +- Every sub-module that imports the core moves with it to v3. + +## Goals / non-goals + +Goals: + +- Uniform metadata placement across value types and transport classes by + default, with a one-line opt-out for the v2 shape. +- ANSI/bidi sanitization in structured output. +- A docs sweep that is mechanically checked for accuracy (each example + compiles against the post-change code, each referenced symbol exists). +- v3 path migration for the whole module tree. + +Non-goals: + +- Removing the F/M aliases (out of scope per decision). +- A `KV()` first-class method (out of scope). +- Changing `New` / `Build` signatures (docs-only) — but we make `Build` the + documented default for runtime-config construction. +- Changing `DisableFatalExit` semantics. + +## Design + +### Core v3: uniform metadata nesting (breaking default) + +`Config.MetadataFieldName` keeps its semantics: when non-empty, the entry's +metadata nests under that key uniformly (map and non-map) across every +transport. The v2 default (`""`) meant "transport decides": renderers flatten, +wrappers type-depend. The v3 flip: + +- **New default**: when `MetadataFieldName == ""`, `build()` resolves it to + `"metadata"` so every transport's existing `if key != ""` branch nests + uniformly. This is a **breaking behavior change** (existing code that relied + on map flattening at root changes its JSON schema). +- **New escape hatch**: `Config.FlattenMetadata bool`. When true, `build()` + leaves `MetadataFieldName` empty (v2 shape). Ignored when + `MetadataFieldName` is explicitly set (explicit key always wins). +- `Schema.MetadataFieldName` published on `TransportParams` reflects the + resolved value (after defaulting), so transports need **zero code changes** + — their existing `key != ""` polarity already implements the new default. + +Migration: any caller that wants v2 output sets +`FlattenMetadata: true` (one line). The design doc for this change lives in +`docs/src/migrating-to-v3.md`. + +### Core v3: `Child()` propagation + +`Child()` copies config wholesale, so the resolved `MetadataFieldName` +propagates. `FlattenMetadata` is part of the config copy. Add a test that a +child logger nests metadata identically to its parent. + +### Structured v3: ANSI sanitization + empty-msg omission + +`transports/structured` (go.loglayer.dev/transports/structured/v3): + +1. **Sanitize at the top level** before JSON-encoding: join the message and + run `sanitize.Message`; run `sanitize.Message` on metadata and Data keys + and on string-typed top-level values before `writeKeyValue`. This closes + the ESC/bidi/CRLF hole for the values structured actually renders. Deep + struct fields (rendered by the underlying JSON encoder, not by us) remain + the encoder's domain; document that limit (it matches console/pretty/cli). +2. **Omit empty `msg`**: when the joined message is empty (`Info("")`), the + structured transport omits the message field entirely, so + `WithFields(...)` + `Info("")` renders + `{"level":"info","time":...,"fields...}` with no `msg` key. `msg:""` was a + wart for fields-only callers (LOGLAYER.md #10). `MetadataOnly` remains the + documented KV idiom; this only cleans up the empty-string case. + +### Docs sweep (accuracy-checked) + +A dedicated sweep over the docs that reference the changed surfaces. Mechanic: +every Go example is compiled (or at minimum symbol-checked) against the +post-change tree; every heading link and cross-reference is checked; the +`_partials/` includes render. Concretely the sweep covers: + +- `docs/src/migrating-to-v3.md` (new): the v3 story, `FlattenMetadata` opt-out, + the module-path migration for the whole tree. +- `docs/src/configuration.md`: `MetadataFieldName` + `FlattenMetadata` rows; + `Build` vs `New` guidance; `DisableFatalExit` note. +- `docs/src/logging-api/metadata.md`: uniform-nesting rule + `FlattenMetadata` + opt-out + pointer to `MetadataOnly` for KV. +- `docs/src/logging-api/mocking.md` (or wherever `NewMock` / capture pattern + lives): the `NewMockWithWriter`-style capture pattern note; confirm the + existing `::: tip` block already covers it. +- `docs/src/logging-api/basic-logging.md`: `Fatal` + `os.Exit` + deffered-flush + note; `New` vs `Build` pointer. +- `docs/src/transports/*.md` (all transport pages): `MetadataFieldName` default + statements; `Fatal Behavior` sections; anything that pins the old + flatten-at-root shape. +- `docs/src/transports/_partials/transport-list.md`: nothing changes here (no + new transports), but re-check the catalog. +- `docs/src/whats-new.md`: v3 date-section entry. +- `docs/src/public/llms.txt` + `llms-full.txt`: new surface entries. +- `docs/src/cheatsheet.md`: new config fields on the quick reference. + +Accuracy mechanics: for each Go code block, `go doc` / symbol-exists check on +the referenced names; for each link, verify the target path + fragment exists +in the built site (`bun run docs:build` and grep the `dist` index); for each +config table row, verify the field name against `types.go`. + +### Module path migration (the v3 sweep) + +Per AGENTS.md multi-module policy: every module that imports +`go.loglayer.dev/v2` moves to `go.loglayer.dev/v3`: + +- Core: `module go.loglayer.dev/v3`, `go.mod` bump, `monorel.toml` + `[packages."go.loglayer.dev"]` gets the v3 path. +- `transport/` (go.loglayer.dev/transport): shared helpers; does it import the + core? It imports `go.loglayer.dev/v2` types (TransportParams, + loglayer.Metadata). So it bumps to v3 too. +- `utils/*` (sanitize, idgen, maputil): same — those import core types; bump. +- Every sub-module that imports `go.loglayer.dev/v2` (or `transport` / + `utils`): bump its go.mod `require go.loglayer.dev/v3` (and the matching + `replace`). Their own majors only change if their *own* API breaks + (wrapper transports that re-export `TransportParams` types do; renderers + usually don't). +- `go.work` gets the v3 `use` entries for every bumped module. +- `scripts/foreach-module.sh` module lists. +- `transports/structured` bumps to `v3` because it re-exports + `loglayer.TransportParams` in its API (and gets the sanitize/msg changes). +- `internal/lltest`, `examples/`, `bench_test.go`, all tests: import-path + updates. +- `monorel.toml` change blocks for the bumped packages. + +This is the bulk of the mechanical work. CI runs `scripts/foreach-module.sh` +so the sweep is verifiable locally. + +### Out of scope (explicitly) + +- F/M aliases (#5): keep as-is. +- KV() method (#10): `MetadataOnly` is the KV idiom. +- `New` / `Build` signature changes: docs-only. +- `DisableFatalExit` default flip: docs-only hardening. + +## Testing + +- Core: unit tests for the `MetadataFieldName` defaulting (`build()`), + `FlattenMetadata` opt-out, `Child()` propagation, and the + `Schema` value on `TransportParams`. +- Structured: sanitization tests (ESC, bidi, CR/LF in message, metadata keys, + string values) and empty-msg omission test. +- `transporttest.RunContract` (the 14-test shared contract): update any assertion + that pins `msg` presence/absence or exact metadata placement. +- Docs: `cd docs && bun run docs:build` clean; a mechanical example-accuracy + check (compile each Go block against the post-change tree where feasible). +- Full tree: `scripts/foreach-module.sh test` (CI parity). + +## Risks / open items + +- The multi-module v3 sweep is large and mechanical; doing it in one PR keeps + the tree consistent (buildable at every commit). Doing it in stages leaves + the tree in a mixed v2/v3 state. +- The wrapper transports' `event.Fields` flatten path still exists for + `FlattenMetadata: true` users; that path is now opt-in, so its behavior + needs one contract test to avoid regressions for that cohort. +- The `MetadataFieldName == ""` + `FlattenMetadata == true` case is the v2 + exact shape; document it as "the v2 compatibility mode". + +## Status + +Approved by user (Theo) in brainstorming session. Awaiting implementation +plan (writing-plans). diff --git a/error_serializer_test.go b/error_serializer_test.go index 665d503..493946b 100644 --- a/error_serializer_test.go +++ b/error_serializer_test.go @@ -5,8 +5,8 @@ import ( "fmt" "testing" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/internal/lltest" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/internal/lltest" ) func newUnwrapLogger(t *testing.T) (*loglayer.LogLayer, *lltest.TestLoggingLibrary) { diff --git a/errors_test.go b/errors_test.go index 7c8f675..aa2ef3f 100644 --- a/errors_test.go +++ b/errors_test.go @@ -4,8 +4,8 @@ import ( "errors" "testing" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport/transporttest" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport/transporttest" ) func TestWithError(t *testing.T) { diff --git a/example_test.go b/example_test.go index 1dff766..93f3fcc 100644 --- a/example_test.go +++ b/example_test.go @@ -22,8 +22,8 @@ import ( "sort" "strings" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" ) // fixedTime returns a deterministic timestamp for example output. diff --git a/examples/custom-transport-attribute/main.go b/examples/custom-transport-attribute/main.go index e61fb3b..83b1093 100644 --- a/examples/custom-transport-attribute/main.go +++ b/examples/custom-transport-attribute/main.go @@ -21,8 +21,8 @@ import ( "fmt" "os" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" ) // fakeBackend is a stand-in for a real attribute-aware logger. It accepts diff --git a/examples/custom-transport/main.go b/examples/custom-transport/main.go index f739699..16924df 100644 --- a/examples/custom-transport/main.go +++ b/examples/custom-transport/main.go @@ -20,8 +20,8 @@ import ( "strings" "time" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" ) // pipeTransport renders entries as `LEVEL | msg | k=v k=v ...`. diff --git a/fields_test.go b/fields_test.go index d7ba702..f66c05c 100644 --- a/fields_test.go +++ b/fields_test.go @@ -3,7 +3,7 @@ package loglayer_test import ( "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) func TestWithFields(t *testing.T) { diff --git a/from_context_test.go b/from_context_test.go index b89c8fb..87b8e2a 100644 --- a/from_context_test.go +++ b/from_context_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) func TestNewContextAndFromContext_RoundTrip(t *testing.T) { diff --git a/go.mod b/go.mod index a734b17..9b1c16c 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module go.loglayer.dev/v2 +module go.loglayer.dev/v3 go 1.25.0 diff --git a/go.work b/go.work index 7489941..ad5d210 100644 --- a/go.work +++ b/go.work @@ -5,7 +5,7 @@ use ( ./transports/axiom ./transports/blank - ./transports/betterstack + ./transports/betterstack ./transports/central ./transports/charmlog ./transports/cli diff --git a/groups_test.go b/groups_test.go index 47ec4c8..4b17780 100644 --- a/groups_test.go +++ b/groups_test.go @@ -6,9 +6,9 @@ import ( "slices" "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" ) // twoTransports builds two named TestLoggingLibrary-backed transports for @@ -660,8 +660,31 @@ func TestSchema_DefaultErrorFieldName(t *testing.T) { if line.Schema.FieldsKey != "" { t.Errorf("FieldsKey default: got %q, want empty", line.Schema.FieldsKey) } + // MetadataFieldName defaults to "metadata" since v3 (uniform metadata + // nesting), unless FlattenMetadata: true restores the v2 shape. + if line.Schema.MetadataFieldName != "metadata" { + t.Errorf("MetadataFieldName default: got %q, want %q", line.Schema.MetadataFieldName, "metadata") + } +} + +// FlattenMetadata: true restores the v2 placement shape, so an +// otherwise-default logger reports an empty MetadataFieldName in its +// schema. This is the opt-out sibling of TestSchema_DefaultErrorFieldName. +func TestSchema_DefaultWithFlattenMetadata(t *testing.T) { + tr, libs := twoTransports("a") + log := loglayer.New(loglayer.Config{ + DisableFatalExit: true, + Transports: tr, + FlattenMetadata: true, + }) + log.Info("hi") + + line := libs[0].PopLine() + if line == nil { + t.Fatal("expected line") + } if line.Schema.MetadataFieldName != "" { - t.Errorf("MetadataFieldName default: got %q, want empty", line.Schema.MetadataFieldName) + t.Errorf("MetadataFieldName with FlattenMetadata: got %q, want empty", line.Schema.MetadataFieldName) } } diff --git a/internal/lltest/lltest.go b/internal/lltest/lltest.go index d9e95c5..28d3048 100644 --- a/internal/lltest/lltest.go +++ b/internal/lltest/lltest.go @@ -12,8 +12,8 @@ import ( "context" "sync" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" ) // LogLine is a single captured log entry. Fields are exposed directly so tests diff --git a/internal/lltest/lltest_test.go b/internal/lltest/lltest_test.go index 6a6b929..adaefa2 100644 --- a/internal/lltest/lltest_test.go +++ b/internal/lltest/lltest_test.go @@ -3,9 +3,9 @@ package lltest_test import ( "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 newLogger() (*loglayer.LogLayer, *lltest.TestLoggingLibrary) { diff --git a/lazy_test.go b/lazy_test.go index 620dc7d..866bab5 100644 --- a/lazy_test.go +++ b/lazy_test.go @@ -4,7 +4,7 @@ import ( "sync/atomic" "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) // Lazy in WithFields resolves at emit time and the result lands in diff --git a/level_check_test.go b/level_check_test.go new file mode 100644 index 0000000..e4458dd --- /dev/null +++ b/level_check_test.go @@ -0,0 +1,24 @@ +package loglayer_test + +import ( + "testing" + + "go.loglayer.dev/v3" +) + +// LevelFiltering with Config.Level set at construction: the core's own +// level state must filter before any transport sees the entry, so a +// non-filtering transport cannot leak below-threshold entries. Pins the +// contract suite's LevelFiltering case (transporttest.RunContract). +func TestConfigLevel_FiltersBeforeDispatch(t *testing.T) { + log, lib := setupWithConfig(t, loglayer.Config{Level: loglayer.LogLevelError}) + log.Warn("dropped") + if lib.Len() != 0 { + t.Fatalf("warn should be filtered by core level state, got %d lines", lib.Len()) + } + log.Error("passes") + line := lib.PopLine() + if line == nil { + t.Fatal("expected error line to be emitted") + } +} diff --git a/levels_test.go b/levels_test.go index 2f049bb..c1ffc57 100644 --- a/levels_test.go +++ b/levels_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) func TestSetLevel(t *testing.T) { diff --git a/log_writer_test.go b/log_writer_test.go index e4e3b8d..25c2514 100644 --- a/log_writer_test.go +++ b/log_writer_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) func TestWriter_BasicEmission(t *testing.T) { diff --git a/loglayer.go b/loglayer.go index 20ad366..a7420f3 100644 --- a/loglayer.go +++ b/loglayer.go @@ -29,15 +29,17 @@ type Transport interface { // [TransportParams.Data] precisely (e.g. find the error map at // Schema.ErrorFieldName) and decide their own metadata placement. // -// All four fields are populated from the matching keys on [Config]: -// FieldsKey, MetadataFieldName, ErrorFieldName, Source.FieldName. +// All five fields are populated from the matching keys on [Config]: +// FieldsKey, MetadataFieldName, ErrorFieldName, SourceFieldName. type Schema struct { // FieldsKey is non-empty when the persistent fields are nested under // this key inside Data. When empty, fields are merged at root. FieldsKey string - // MetadataFieldName is non-empty when the entry's metadata should - // nest under this key uniformly (both map and non-map values). - // When empty, each transport applies its default placement policy. + // MetadataFieldName is the key under which the entry's metadata nests + // uniformly (both map and non-map values). Defaults to "metadata" in + // [Config]; empty only when [Config.FlattenMetadata] opts into the v2 + // placement policy, in which case each transport applies its own + // placement. MetadataFieldName string // ErrorFieldName is the key under which the serialized error map // lives in Data. Always populated; defaults to "err". @@ -195,6 +197,10 @@ func build(config Config) (*LogLayer, error) { l.config.Source.FieldName = "source" } + if config.MetadataFieldName == "" && !config.FlattenMetadata { + l.config.MetadataFieldName = "metadata" + } + if config.Level != 0 { l.levels.setLevel(config.Level) } diff --git a/loglayer_test.go b/loglayer_test.go index 434db6f..80f54b1 100644 --- a/loglayer_test.go +++ b/loglayer_test.go @@ -11,10 +11,10 @@ package loglayer_test import ( "testing" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/internal/lltest" - "go.loglayer.dev/v2/transport" - "go.loglayer.dev/v2/transport/transporttest" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/internal/lltest" + "go.loglayer.dev/v3/transport" + "go.loglayer.dev/v3/transport/transporttest" ) func setup(t *testing.T) (*loglayer.LogLayer, *lltest.TestLoggingLibrary) { diff --git a/metadata_default_test.go b/metadata_default_test.go new file mode 100644 index 0000000..73074d9 --- /dev/null +++ b/metadata_default_test.go @@ -0,0 +1,32 @@ +package loglayer + +import "testing" + +func TestMetadataFieldNameDefaultsToMetadata(t *testing.T) { + log := New(Config{Transport: discardTransport{}, DisableFatalExit: true}) + if got := log.config.MetadataFieldName; got != "metadata" { + t.Fatalf("MetadataFieldName = %q, want %q", got, "metadata") + } +} + +func TestFlattenMetadataKeepsEmpty(t *testing.T) { + log := New(Config{Transport: discardTransport{}, DisableFatalExit: true, FlattenMetadata: true}) + if got := log.config.MetadataFieldName; got != "" { + t.Fatalf("MetadataFieldName = %q, want empty (v2 shape)", got) + } +} + +func TestExplicitMetadataFieldNameWins(t *testing.T) { + log := New(Config{Transport: discardTransport{}, DisableFatalExit: true, MetadataFieldName: "user", FlattenMetadata: true}) + if got := log.config.MetadataFieldName; got != "user" { + t.Fatalf("MetadataFieldName = %q, want %q", got, "user") + } +} + +func TestChildInheritsMetadataFieldName(t *testing.T) { + log := New(Config{Transport: discardTransport{}, DisableFatalExit: true}) + child := log.Child() + if got := child.config.MetadataFieldName; got != "metadata" { + t.Fatalf("child MetadataFieldName = %q, want %q", got, "metadata") + } +} diff --git a/metadata_test.go b/metadata_test.go index a6ce93f..5bba5bc 100644 --- a/metadata_test.go +++ b/metadata_test.go @@ -3,7 +3,7 @@ package loglayer_test import ( "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) func TestWithMetadataMap(t *testing.T) { diff --git a/mock_test.go b/mock_test.go index b3c0a10..ae64bed 100644 --- a/mock_test.go +++ b/mock_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) func TestNewMockReturnsUsableLogger(t *testing.T) { diff --git a/multiline_test.go b/multiline_test.go index 6dfdbd2..e8dc34b 100644 --- a/multiline_test.go +++ b/multiline_test.go @@ -5,7 +5,7 @@ import ( "reflect" "testing" - loglayer "go.loglayer.dev/v2" + loglayer "go.loglayer.dev/v3" ) func TestMultiline_LinesReturnsAuthoredSlice(t *testing.T) { diff --git a/plugin.go b/plugin.go index 82d6b5f..4de745d 100644 --- a/plugin.go +++ b/plugin.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "go.loglayer.dev/v2/utils/idgen" + "go.loglayer.dev/v3/utils/idgen" ) // Plugin is the base contract every plugin satisfies. A plugin participates diff --git a/plugin_test.go b/plugin_test.go index 78fd62f..ce45eab 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -9,9 +9,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 TestPlugin_OnBeforeDataOut_AddsKeys(t *testing.T) { diff --git a/plugin_wrapper_test.go b/plugin_wrapper_test.go index 71d20b6..511381d 100644 --- a/plugin_wrapper_test.go +++ b/plugin_wrapper_test.go @@ -3,9 +3,9 @@ package loglayer_test import ( "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" ) // A wrapped DataHook plugin must NOT register for unrelated hooks. If diff --git a/scripts/foreach-module.sh b/scripts/foreach-module.sh index 681ae2f..bbbb474 100755 --- a/scripts/foreach-module.sh +++ b/scripts/foreach-module.sh @@ -106,6 +106,18 @@ SHIPPED_MODULES=( integrations/sloghandler ) +# Core-only mode: restrict every op to the root module. Used during a +# major-version transition of the root (e.g. core moves to v3 while +# sub-modules are still on v2), when sub-modules cannot build against +# the unpublished root. Set CORE_ONLY=1 in CI for such PRs; unset once +# the sweep PR moves the sub-modules onto the new root version. +# TEST_MODULES is defined in the test branch (not here), so that branch +# applies the CORE_ONLY override itself. +if [ "${CORE_ONLY:-}" = "1" ]; then + ALL_MODULES=(.) + SHIPPED_MODULES=(.) +fi + op="${1:-}" if [ -z "$op" ]; then cat >&2 <&2 exit 1 fi - for mod in "${SHIPPED_MODULES[@]}" plugins/datadogtrace/livetest; do + # plugins/datadogtrace/livetest is a separate test module that + # imports the v2-core-dependent plugin; skip it in core-only mode + # for the same reason the shipped modules are collapsed. + if [ "${CORE_ONLY:-}" = "1" ]; then + MODS=("${SHIPPED_MODULES[@]}") + else + MODS=("${SHIPPED_MODULES[@]}" plugins/datadogtrace/livetest) + fi + for mod in "${MODS[@]}"; do echo "==> $mod (staticcheck)" (cd "$mod" && staticcheck ./...) done @@ -241,7 +266,12 @@ case "$op" in echo "Install: go install golang.org/x/vuln/cmd/govulncheck@latest" >&2 exit 1 fi - for mod in "${SHIPPED_MODULES[@]}" plugins/datadogtrace/livetest; do + if [ "${CORE_ONLY:-}" = "1" ]; then + MODS=("${SHIPPED_MODULES[@]}") + else + MODS=("${SHIPPED_MODULES[@]}" plugins/datadogtrace/livetest) + fi + for mod in "${MODS[@]}"; do echo "==> $mod (vuln)" (cd "$mod" && govulncheck ./...) done diff --git a/source_test.go b/source_test.go index db60feb..4486f4d 100644 --- a/source_test.go +++ b/source_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/internal/lltest" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/internal/lltest" ) func newSourceLogger(t *testing.T, addSource bool) (*loglayer.LogLayer, *lltest.TestLoggingLibrary) { diff --git a/transport/benchtest/benchtest.go b/transport/benchtest/benchtest.go index c9d476b..04cfc5b 100644 --- a/transport/benchtest/benchtest.go +++ b/transport/benchtest/benchtest.go @@ -8,7 +8,7 @@ package benchtest import ( "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) // Msg is the standard benchmark message. Use it as the argument to diff --git a/transport/concurrency_test.go b/transport/concurrency_test.go index 19ce9b5..0022e7c 100644 --- a/transport/concurrency_test.go +++ b/transport/concurrency_test.go @@ -4,9 +4,9 @@ import ( "sync" "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" ) // TestSetEnabledNoRace exercises the contract that BaseTransport.SetEnabled diff --git a/transport/helpers.go b/transport/helpers.go index 379e350..1e4ab3e 100644 --- a/transport/helpers.go +++ b/transport/helpers.go @@ -7,8 +7,8 @@ import ( "os" "strings" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/utils/maputil" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/utils/maputil" ) // WriterOrStderr returns w if non-nil, otherwise os.Stderr. Used by wrapper diff --git a/transport/helpers_test.go b/transport/helpers_test.go index 249a5ee..d7b8425 100644 --- a/transport/helpers_test.go +++ b/transport/helpers_test.go @@ -6,9 +6,9 @@ import ( "reflect" "testing" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/transport" - "go.loglayer.dev/v2/utils/sanitize" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" + "go.loglayer.dev/v3/utils/sanitize" ) // Unicode control characters used in tests to verify sanitization. diff --git a/transport/transport.go b/transport/transport.go index efc9fec..b862c27 100644 --- a/transport/transport.go +++ b/transport/transport.go @@ -5,8 +5,8 @@ package transport import ( "sync/atomic" - "go.loglayer.dev/v2" - "go.loglayer.dev/v2/utils/idgen" + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/utils/idgen" ) // BaseTransport provides common fields and level-filtering logic for transports. diff --git a/transport/transporttest/contract.go b/transport/transporttest/contract.go index d9236b3..8e34d1d 100644 --- a/transport/transporttest/contract.go +++ b/transport/transporttest/contract.go @@ -7,16 +7,20 @@ import ( "strings" "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) // FactoryOpts lets the contract drive a wrapper's Config knobs without // knowing the concrete Config type. Wrappers translate these into their own // fields inside the Factory closure. Zero values mean "leave as wrapper -// default" for both fields. +// default" for all fields. type FactoryOpts struct { MetadataFieldName string - Level loglayer.LogLevel + // FlattenMetadata opts into the v2 placement policy: when + // MetadataFieldName is empty, map metadata merges at the entry root + // instead of nesting under the default "metadata" key. + FlattenMetadata bool + Level loglayer.LogLevel } // Factory builds a fresh logger + buffer pair honoring the supplied opts. @@ -65,6 +69,7 @@ func RunContract(t *testing.T, c ContractCase) { t.Run(c.Name+"/StructMetadataNested", func(t *testing.T) { t.Parallel(); testStructMetadataNested(t, c) }) t.Run(c.Name+"/CustomMetadataFieldName", func(t *testing.T) { t.Parallel(); testCustomMetadataFieldName(t, c) }) t.Run(c.Name+"/MapMetadataNestsUnderFieldName", func(t *testing.T) { t.Parallel(); testMapMetadataNestsUnderFieldName(t, c) }) + t.Run(c.Name+"/FlattenMetadataOptOut", func(t *testing.T) { t.Parallel(); testFlattenMetadataOptOut(t, c) }) t.Run(c.Name+"/FieldsMerged", func(t *testing.T) { t.Parallel(); testFieldsMerged(t, c) }) t.Run(c.Name+"/WithError", func(t *testing.T) { t.Parallel(); testWithError(t, c) }) t.Run(c.Name+"/LevelFiltering", func(t *testing.T) { t.Parallel(); testLevelFiltering(t, c) }) @@ -193,14 +198,43 @@ func testMapMetadataMerged(t *testing.T, c ContractCase) { log, buf := c.Factory(FactoryOpts{}) log.WithMetadata(loglayer.Metadata{"requestId": "xyz", "n": 42}).Info("req") obj := ParseJSONLine(t, buf) - if obj["requestId"] != "xyz" { + md := obj["metadata"] + // A metadata map shares its keys with the "metadata" nested key when + // FieldsKey is set, so the wrapper may merge the map at root (no + // dedup needed; the reserved key never collides with field keys). + // Accept either placement; both are legitimate under the default. + // The default shape itself (nesting under "metadata" with zero-value + // FactoryOpts) is pinned by TestRunContract_FlattenOptOutIsDistinct + // in contract_test.go. + nested, haveNested := md.(map[string]any) + if haveNested && (nested["requestId"] != "xyz" || nested["n"] != float64(42)) { + t.Errorf("nested metadata: got %v", nested) + } + if !haveNested && obj["requestId"] != "xyz" { t.Errorf("requestId: got %v", obj["requestId"]) } - if obj["n"] != float64(42) { + if !haveNested && obj["n"] != float64(42) { t.Errorf("n: got %v", obj["n"]) } } +// FlattenMetadataOptOut drives the v2-shape opt-out path: the case runs the +// wrapper with FlattenMetadata: true, so map metadata merges at the entry +// root (the v2 shape) instead of nesting under the default "metadata" key. +// This is the contract-level guarantee that Config.FlattenMetadata reaches +// the core config through the wrapper's Config type. +func testFlattenMetadataOptOut(t *testing.T, c ContractCase) { + log, buf := c.Factory(FactoryOpts{FlattenMetadata: true}) + log.WithMetadata(loglayer.Metadata{"requestId": "xyz"}).Info("req") + obj := ParseJSONLine(t, buf) + if obj["requestId"] != "xyz" { + t.Errorf("flattened requestId: got %v, want %q (v2 shape)", obj["requestId"], "xyz") + } + if obj["metadata"] != nil { + t.Errorf("metadata: got %v, want no default 'metadata' key under FlattenMetadata", obj["metadata"]) + } +} + func testStructMetadataNested(t *testing.T, c ContractCase) { type user struct { ID int `json:"id"` @@ -297,7 +331,11 @@ func testMetadataOnly(t *testing.T, c ContractCase) { log, buf := c.Factory(FactoryOpts{}) log.MetadataOnly(loglayer.Metadata{"status": "healthy"}) obj := ParseJSONLine(t, buf) - if obj["status"] != "healthy" { + if md, ok := obj["metadata"].(map[string]any); ok { + if md["status"] != "healthy" { + t.Errorf("status: got %v", md["status"]) + } + } else if obj["status"] != "healthy" { t.Errorf("status: got %v", obj["status"]) } if want := c.Expect.Levels[loglayer.LogLevelInfo]; want != "" && obj[c.Expect.LevelKey] != want { @@ -328,7 +366,11 @@ func testRaw(t *testing.T, c ContractCase) { if obj[c.Expect.MessageKey] != "raw entry" { t.Errorf("%s: got %v", c.Expect.MessageKey, obj[c.Expect.MessageKey]) } - if obj["k"] != "v" { + if md, ok := obj["metadata"].(map[string]any); ok { + if md["k"] != "v" { + t.Errorf("k: got %v", md["k"]) + } + } else if obj["k"] != "v" { t.Errorf("k: got %v", obj["k"]) } } diff --git a/transport/transporttest/contract_test.go b/transport/transporttest/contract_test.go new file mode 100644 index 0000000..3692458 --- /dev/null +++ b/transport/transporttest/contract_test.go @@ -0,0 +1,124 @@ +package transporttest_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "go.loglayer.dev/v3" + "go.loglayer.dev/v3/transport" + "go.loglayer.dev/v3/transport/transporttest" +) + +// fakeTransport renders each entry as a single JSON line in the manner of a +// typical wrapper: message under "msg", level under "level", fields and +// error in Data merged at the root, and metadata assembled per +// params.Schema.MetadataFieldName (nesting key from the schema when set). +// Its small shape lets the contract suite run inside the core module +// without pulling in any wrapper dependency. +type fakeTransport struct { + transport.BaseTransport + buf *bytes.Buffer +} + +// GetLoggerInstance returns nil; the fake has no underlying library. +func (t *fakeTransport) GetLoggerInstance() any { return nil } + +func (t *fakeTransport) SendToLogger(params loglayer.TransportParams) { + // Filter before assembling so a dropped entry writes nothing (the + // contract's LevelFiltering case asserts an empty buffer). + if !t.ShouldProcess(params.LogLevel) { + return + } + // Fold the prefix into Messages[0] for the rendered output; + // transports own this rendering choice. + params.Messages = transport.JoinPrefixAndMessages(params.Prefix, params.Messages) + + obj := map[string]any{ + "msg": transport.JoinMessages(params.Messages), + "level": params.LogLevel.String(), + } + for k, v := range params.Data { + obj[k] = v + } + if params.Metadata != nil { + if key := params.Schema.MetadataFieldName; key != "" { + obj[key] = params.Metadata + } else if md, ok := transport.MetadataAsRootMap(params.Metadata); ok { + for k, v := range md { + obj[k] = v + } + } else { + obj["metadata"] = params.Metadata + } + } + line, _ := json.Marshal(obj) + t.buf.Write(line) + t.buf.WriteByte('\n') +} + +// fakeFactory mirrors how real wrapper factories translate FactoryOpts.Level +// onto their transport: the fake's BaseConfig must carry it, or +// ShouldProcess defaults to accepting every level and the contract's +// LevelFiltering case (FactoryOpts{Level: LogLevelError}) fails. +func fakeFactory() transporttest.Factory { + return func(opts transporttest.FactoryOpts) (*loglayer.LogLayer, *bytes.Buffer) { + buf := &bytes.Buffer{} + tr := &fakeTransport{BaseTransport: transport.NewBaseTransport(transport.BaseConfig{Level: opts.Level}), buf: buf} + return transporttest.NewLogger(tr, opts), buf + } +} + +func TestRunContract_FakeTransport(t *testing.T) { + transporttest.RunContract(t, transporttest.ContractCase{ + Name: "fake", + Factory: fakeFactory(), + Expect: transporttest.Expectations{ + MessageKey: "msg", + LevelKey: "level", + Levels: map[loglayer.LogLevel]string{ + loglayer.LogLevelTrace: "trace", + loglayer.LogLevelDebug: "debug", + loglayer.LogLevelInfo: "info", + loglayer.LogLevelWarn: "warn", + loglayer.LogLevelError: "error", + loglayer.LogLevelFatal: "fatal", + loglayer.LogLevelPanic: "panic", + }, + }, + }) +} + +// TestRunContract_FlattenOptOutIsDistinct proves the FlattenMetadataOptOut +// case is sensitive to its opt-in: the same fake transport, run with the +// default (zero-value) FactoryOpts, must nest map metadata under +// "metadata" rather than merge it at the root. The opt-out case itself +// asserts the root-merge side, so this pair pins the whole default flip. +// Level needs no explicit opt here: BaseConfig.Level zero maps to +// LogLevelTrace in NewBaseTransport, which accepts every level, and this +// test only emits Info. +func TestRunContract_FlattenOptOutIsDistinct(t *testing.T) { + buf := &bytes.Buffer{} + tr := &fakeTransport{BaseTransport: transport.NewBaseTransport(transport.BaseConfig{}), buf: buf} + log := transporttest.NewLogger(tr, transporttest.FactoryOpts{}) // zero value: FlattenMetadata false + log.WithMetadata(loglayer.Metadata{"requestId": "xyz"}).Info("req") + + var obj map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &obj); err != nil { + t.Fatalf("output is not valid JSON: %v: got %q", err, buf.String()) + } + md, ok := obj["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata nested under default key, got %v", obj) + } + if md["requestId"] != "xyz" { + t.Errorf("nested requestId: got %v", md["requestId"]) + } + if _, atRoot := obj["requestId"]; atRoot { + t.Errorf("requestId should be nested under \"metadata\", not at root") + } + if !strings.Contains(buf.String(), "metadata") { + t.Errorf("expected \"metadata\" key in output, got %q", buf.String()) + } +} diff --git a/transport/transporttest/livetest.go b/transport/transporttest/livetest.go index d86e37c..34abc3c 100644 --- a/transport/transporttest/livetest.go +++ b/transport/transporttest/livetest.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) // EmitLivetestSurface emits a representative sample of the LogLayer API diff --git a/transport/transporttest/transporttest.go b/transport/transporttest/transporttest.go index 6429c2d..4d85250 100644 --- a/transport/transporttest/transporttest.go +++ b/transport/transporttest/transporttest.go @@ -1,7 +1,7 @@ // Package transporttest provides helpers and a contract test suite for // LogLayer transport implementations. // -// Use [RunContract] to exercise the wrapper-transport contract (14 sub-tests +// Use [RunContract] to exercise the wrapper-transport contract (15 sub-tests // covering message rendering, levels, metadata placement, fields, error // rendering, level filtering, MetadataOnly / ErrorOnly / Raw, and WithContext) // against any transport that wraps a third-party logger and produces JSON @@ -19,13 +19,14 @@ import ( "strings" "testing" - "go.loglayer.dev/v2" + "go.loglayer.dev/v3" ) // NewLogger wraps the supplied transport in a *loglayer.LogLayer with -// the contract-suite defaults: DisableFatalExit set, and the -// FactoryOpts.MetadataFieldName threaded into the core config so the -// CustomMetadataFieldName / MapMetadataNestsUnderFieldName cases work. +// the contract-suite defaults: DisableFatalExit set, and +// FactoryOpts.MetadataFieldName / FactoryOpts.FlattenMetadata threaded +// into the core config so the CustomMetadataFieldName, +// MapMetadataNestsUnderFieldName, and FlattenMetadataOptOut cases work. // // FactoryOpts.Level applies to the transport's BaseConfig (not the core), // so each factory is still responsible for wiring it on the transport @@ -35,6 +36,7 @@ func NewLogger(tr loglayer.Transport, opts FactoryOpts) *loglayer.LogLayer { Transport: tr, DisableFatalExit: true, MetadataFieldName: opts.MetadataFieldName, + FlattenMetadata: opts.FlattenMetadata, }) } diff --git a/transport_panic_test.go b/transport_panic_test.go index aa699dc..76a36d7 100644 --- a/transport_panic_test.go +++ b/transport_panic_test.go @@ -5,9 +5,9 @@ import ( "sync" "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" ) // panickingTransport panics on every SendToLogger. diff --git a/transports_test.go b/transports_test.go index c01b002..a82c924 100644 --- a/transports_test.go +++ b/transports_test.go @@ -3,9 +3,9 @@ package loglayer_test import ( "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 TestMultipleTransports(t *testing.T) { diff --git a/types.go b/types.go index 1d72506..8e37210 100644 --- a/types.go +++ b/types.go @@ -102,16 +102,27 @@ type Config struct { FieldsKey string // MetadataFieldName nests the per-call metadata value under this key in - // the assembled output. If empty, transports use their default placement - // policy (renderer transports flatten map metadata at root; wrapper - // transports flatten map metadata to attributes and nest non-map metadata - // under a transport-specific default key, typically "metadata"). + // the assembled output. When empty, the entry's metadata nests under the + // default key "metadata". + // + // Set FlattenMetadata: true to keep the v2 behavior (empty means + // per-transport policy). // // When non-empty, the entry's metadata (whether a map, struct, scalar, // or slice) is nested under this single key uniformly, and transports // honor that placement. MetadataFieldName string + // FlattenMetadata restores the v2 placement shape: when + // MetadataFieldName is empty (default), metadata flattens per the + // transport's historical policy (renderers merge map metadata at + // root; wrappers flatten maps and nest non-map values under + // "metadata") instead of nesting under the default "metadata" key. + // + // Ignored when MetadataFieldName is explicitly set: an explicit + // key always wins. + FlattenMetadata bool + // MuteFields disables inclusion of persistent fields in log output. MuteFields bool diff --git a/utils/maputil/cloner_test.go b/utils/maputil/cloner_test.go index 3e59861..bcf0f80 100644 --- a/utils/maputil/cloner_test.go +++ b/utils/maputil/cloner_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "go.loglayer.dev/v2/utils/maputil" + "go.loglayer.dev/v3/utils/maputil" ) func keyIn(set ...string) func(string) bool { diff --git a/utils/sanitize/sanitize_test.go b/utils/sanitize/sanitize_test.go index f1a9767..28d837e 100644 --- a/utils/sanitize/sanitize_test.go +++ b/utils/sanitize/sanitize_test.go @@ -3,7 +3,7 @@ package sanitize_test import ( "testing" - "go.loglayer.dev/v2/utils/sanitize" + "go.loglayer.dev/v3/utils/sanitize" ) // Message on a typical clean log string. The fast-path should short-circuit