diff --git a/.changeset/cli-table-column-order.md b/.changeset/cli-table-column-order.md new file mode 100644 index 0000000..798f6cf --- /dev/null +++ b/.changeset/cli-table-column-order.md @@ -0,0 +1,10 @@ +--- +"transports/cli": minor +--- + +New `Config.TableColumnOrder []string` knob pins the leading column order for +slice-of-map metadata renderings. Keys named here render in the listed order; +remaining keys sort lexicographically and follow. Pinned keys absent from +every row are silently skipped, so the same `TableColumnOrder` is safe to +reuse across call sites with different row shapes. Empty / nil falls back to +the previous fully-lexicographic behavior. Resolves [#66](https://github.com/loglayer/loglayer-go/issues/66). diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md index 562ee21..8b6d8ce 100644 --- a/.claude/rules/documentation.md +++ b/.claude/rules/documentation.md @@ -166,7 +166,7 @@ When adding a transport, plugin, or integration: 2. **If you split**, mirror the structure used by `transports/otellog/`: own `go.mod` with `module go.loglayer.dev/`, `replace go.loglayer.dev => ../...` for development, a placeholder `require go.loglayer.dev v0.0.0-...` line that the replace directive overrides. Add a CI step in `.github/workflows/ci.yml` that `cd`s into the new module and runs tests. Update the `Mostly single Go module` bullet in AGENTS.md "Key Design Decisions" with the new module path. -3. **If you don't split and the floor moves**, update `go.mod`, the matrix in `.github/workflows/ci.yml`, and the version statements in `README.md`, `docs/src/getting-started.md`, and `AGENTS.md`. Mention the bump in `CHANGELOG.md` and `docs/src/whats-new.md`. +3. **If you don't split and the floor moves**, update `go.mod`, the matrix in `.github/workflows/ci.yml`, and the version statements in `README.md`, `docs/src/getting-started.md`, and `AGENTS.md`. Add a `.changeset/*.md` for the affected module(s) at the appropriate bump level and note the floor change in `docs/src/whats-new.md`. 4. **Per-transport/plugin pages** for split modules need an `::: info Separate module` block at the top stating the import path and floor. Pages for sub-packages of the main module default to "inherits the module's floor" without restating the number; only call it out when the floor differs from the main module. @@ -361,7 +361,7 @@ Update all of these: 2. **`docs/src/whats-new.md`**: add a bullet under today's `## MMM DD, YYYY` date section, beneath the appropriate `` `module-or-version`: `` paragraph (creating the section and/or paragraph if they don't exist). Format per the rules above. 3. **`docs/src/public/llms.txt`**: concise LLM-facing reference. Add a link or bullet for the new surface. 4. **`docs/src/public/llms-full.txt`**: comprehensive LLM-facing reference. Add a section or bullet describing the new surface. -5. **`CHANGELOG.md`** (repo root): add an entry under `## [Unreleased]` in the appropriate component subsection. Format follows [Keep a Changelog](https://keepachangelog.com). +5. **`.changeset/.md`**: add a changeset naming the affected package(s) and bump level (`:major` / `:minor` / `:patch`) so the release pipeline picks it up. Use `monorel add --package ":" --message "..."` or hand-roll the file. `CHANGELOG.md` files (root + per-package) are written from these by `monorel release`; do not edit them by hand. 6. The relevant doc page (e.g. `configuration.md`, the `logging-api/` page, or the `transports/.md` page). For a brand-new transport, also see "When Adding a New Transport" above. @@ -385,4 +385,4 @@ Document this in the method's GoDoc comment. The current contract is summarized ## Versioning Note -The repo is currently a single Go module. Do not create per-package `CHANGELOG.md` files. All entries go in the root `CHANGELOG.md`, grouped by component under each release. See `AGENTS.md` for the full versioning policy. +The repo is multi-module: every transport, plugin, and integration ships as its own Go module with its own `CHANGELOG.md`. Both the root and per-package `CHANGELOG.md` files are written from `.changeset/*.md` files by `monorel release` at release time; do not edit them by hand. See `AGENTS.md` for the full versioning policy and the changeset workflow. diff --git a/docs/src/public/llms-full.txt b/docs/src/public/llms-full.txt index ef68258..37be7a6 100644 --- a/docs/src/public/llms-full.txt +++ b/docs/src/public/llms-full.txt @@ -645,12 +645,15 @@ Tuned for command-line application output rather than diagnostic logging. Short import "go.loglayer.dev/transports/cli/v2" cli.New(cli.Config{ - // Writer: os.Stdout, // override (defaults: stdout for info/debug, stderr for warn+) - // Color: cli.ColorAuto, // ColorNever / ColorAlways - // ShowFields: false, // append fields/metadata after the message + // Writer: os.Stdout, // override (defaults: stdout for info/debug, stderr for warn+) + // Color: cli.ColorAuto, // ColorNever / ColorAlways + // ShowFields: false, // append fields/metadata after the message + // TableColumnOrder: []string{"package"}, // pin leading columns for slice-of-map metadata tables; rest sort lex }) ``` +For slice-of-map metadata (`[]loglayer.Metadata`, `[]map[string]any`, `[]MyStruct`), the cli transport renders a tabwriter-aligned table after the message. Columns sort lexicographically by default; `TableColumnOrder` pins specific leading columns (additive: pinned keys first in listed order, remaining keys lex-sorted and appended; pinned keys absent from every row are silently skipped). + ### Testing Transport In-memory capture for assertion tests. Public package is `transports/testing`; `lltest` is the conventional import alias. diff --git a/docs/src/public/llms.txt b/docs/src/public/llms.txt index b5bd246..6699389 100644 --- a/docs/src/public/llms.txt +++ b/docs/src/public/llms.txt @@ -355,7 +355,7 @@ lines := lib.Lines() // []lltest.LogLine; assert on Level, Messages, Data, Meta - `transports/console`: plain `fmt.Println`-style - `transports/structured`: one JSON object per entry (production) - `transports/pretty`: colorized terminal output (local dev) -- `transports/cli`: tuned for command-line apps (short level prefixes, stdout/stderr routing, TTY-detected color, no timestamps) +- `transports/cli`: tuned for command-line apps (short level prefixes, stdout/stderr routing, TTY-detected color, no timestamps, table rendering for slice-of-map metadata with `Config.TableColumnOrder` to pin leading columns) - `transports/testing`: in-memory capture for tests - `transports/blank`: user-supplied dispatch function diff --git a/docs/src/transports/cli.md b/docs/src/transports/cli.md index 1f63832..f6c4819 100644 --- a/docs/src/transports/cli.md +++ b/docs/src/transports/cli.md @@ -103,7 +103,7 @@ v0.2.0 transports/bar v1.0.0 Rules: -- **Column order**: union of keys across all rows, sorted lexicographically. Stable across runs. +- **Column order**: union of keys across all rows, sorted lexicographically by default. Stable across runs. Pin specific leading columns via [`Config.TableColumnOrder`](#pinning-column-order) below. - **Column header**: each key uppercased. - **Missing values**: empty cell. - **Padding**: two spaces between columns (matches `gh`, `kubectl get`, `cargo`). @@ -115,6 +115,32 @@ Rules: When `ShowFields` is also true, table rendering takes precedence over logfmt for that entry. +### Pinning column order + +Lexicographic-by-default works for ad-hoc tables, but a CLI status report often wants a specific *identifier* column to lead so the human eye scanning rows can ground itself on each row's "what is this?". Set `Config.TableColumnOrder` to pin the leading columns: + +```go +cli.New(cli.Config{ + TableColumnOrder: []string{"package", "changeset"}, +}) +``` + +```go +log.WithMetadata([]loglayer.Metadata{ + {"package": "transports/foo", "changeset": "abc", "bump": "minor", "summary": "fix"}, + {"package": "transports/bar", "changeset": "def", "bump": "patch", "summary": "doc"}, +}).Info("Plan:") +``` + +``` +Plan: +PACKAGE CHANGESET BUMP SUMMARY +transports/foo abc minor fix +transports/bar def patch doc +``` + +The knob is additive: list only the leading columns you want anchored; the remaining columns sort lexicographically and follow. Pinned keys that don't appear in any row are silently skipped, so the same `TableColumnOrder` is safe to reuse across call sites with different row shapes. + When the entry's level is warn / error / fatal, the headline (prefix + message) is colored, but the table body renders neutral. Tables are data, not warnings; tinting the rows would be visually misleading. ## Using `WithPrefix` diff --git a/docs/src/whats-new.md b/docs/src/whats-new.md index 62fc48b..240c8de 100644 --- a/docs/src/whats-new.md +++ b/docs/src/whats-new.md @@ -21,6 +21,8 @@ description: Latest features and improvements in LogLayer for Go. Initial release. New [CLI transport](/transports/cli) tuned for command-line app output: short level prefixes, stdout / stderr routing, TTY-detected ANSI color, no timestamps. Includes table rendering for slice-of-map metadata so the same call site emits a CLI table and a JSON array depending on the transport. +New `Config.TableColumnOrder []string` knob pins the leading column order for slice-of-map metadata table rendering. Keys named here render in the listed order; the rest sort lexicographically and follow. Empty / nil keeps the previous fully-lexicographic behavior. See [Pinning column order](/transports/cli#pinning-column-order). + ## Apr 30, 2026 `transports/gcplogging`: diff --git a/transports/cli/cli.go b/transports/cli/cli.go index 1df86e7..d670d53 100644 --- a/transports/cli/cli.go +++ b/transports/cli/cli.go @@ -123,6 +123,15 @@ type Config struct { // the transport's prefixes would be redundant. DisableLevelPrefix bool + // TableColumnOrder pins the leading column order for slice-of-map + // metadata renderings. Keys named here render in the listed order; + // keys not in the list are sorted lexicographically and appended + // afterward, so the knob is additive: pin only the leading columns + // that anchor the row (e.g. an identifier column) and let the rest + // sort. Pinned keys that don't appear in any row are silently + // skipped. Nil / empty falls back to fully lexicographic ordering. + TableColumnOrder []string + // LevelColor overrides the default per-level color map. // Missing entries fall back to the defaults: // @@ -266,7 +275,7 @@ func (t *Transport) format(params loglayer.TransportParams) string { var table string switch { case isTableMetadata(params.Metadata): - table = renderTable(asTableRows(params.Metadata)) + table = renderTable(asTableRows(params.Metadata), t.cfg.TableColumnOrder) case t.cfg.ShowFields: if fields := renderLogfmt(transport.MergeFieldsAndMetadata(params)); fields != "" { if body != "" { @@ -518,12 +527,13 @@ func elementAsMap(elem any) map[string]any { } // renderTable produces a tabwriter-aligned table: an uppercase header -// row built from the union of keys (sorted lexicographically), then +// row built from the union of keys (sorted lexicographically, with any +// keys named in order pinned to the front in the listed order), then // one row per input map. Missing values render as empty cells. Uses // two spaces of column padding, matching the conventional CLI table // shape (`gh`, `kubectl get`, `cargo`). -func renderTable(rows []map[string]any) string { - keys := tableColumns(rows) +func renderTable(rows []map[string]any, order []string) string { + keys := tableColumns(rows, order) var b strings.Builder tw := tabwriter.NewWriter(&b, 0, 0, 2, ' ', 0) @@ -551,22 +561,34 @@ func renderTable(rows []map[string]any) string { return strings.TrimRight(b.String(), "\n") } -// tableColumns returns the union of keys across all rows, sorted -// lexicographically. Sorted ordering is required so the output is -// deterministic regardless of the (random) Go map iteration order. -func tableColumns(rows []map[string]any) []string { +// tableColumns returns the union of keys across all rows. Keys named +// in order render first in the listed order; remaining keys sort +// lexicographically and follow. Sorted ordering for the tail is +// required so the output is deterministic regardless of the (random) +// Go map iteration order. Pinned keys absent from every row are +// silently skipped so the knob is forward-compatible with row shapes +// that don't yet exist. +func tableColumns(rows []map[string]any, order []string) []string { seen := make(map[string]struct{}) for _, row := range rows { for k := range row { seen[k] = struct{}{} } } + keys := make([]string, 0, len(seen)) + for _, k := range order { + if _, ok := seen[k]; ok { + keys = append(keys, k) + delete(seen, k) + } + } + rest := make([]string, 0, len(seen)) for k := range seen { - keys = append(keys, k) + rest = append(rest, k) } - sort.Strings(keys) - return keys + sort.Strings(rest) + return append(keys, rest...) } func needsQuote(s string) bool { diff --git a/transports/cli/cli_test.go b/transports/cli/cli_test.go index 4593015..57e3f22 100644 --- a/transports/cli/cli_test.go +++ b/transports/cli/cli_test.go @@ -371,6 +371,106 @@ func TestTableRenderingDeterministicColumnOrder(t *testing.T) { } } +func TestTableColumnOrderPinsLeadingColumns(t *testing.T) { + log, stdout, _ := makeLogger(t, clitr.Config{ + TableColumnOrder: []string{"package", "changeset"}, + }) + + log.WithMetadata([]loglayer.Metadata{ + {"bump": "minor", "changeset": "abc", "package": "transports/foo", "summary": "fix"}, + {"bump": "patch", "changeset": "def", "package": "transports/bar", "summary": "doc"}, + }).Info("status:") + + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if len(lines) < 2 { + t.Fatalf("expected headline plus header row, got: %q", stdout.String()) + } + header := lines[1] + + // PACKAGE first, CHANGESET second; BUMP and SUMMARY follow lex-sorted. + wantOrder := []string{"PACKAGE", "CHANGESET", "BUMP", "SUMMARY"} + idx := 0 + for _, want := range wantOrder { + hit := strings.Index(header[idx:], want) + if hit < 0 { + t.Fatalf("header missing %q in %q", want, header) + } + idx += hit + len(want) + } +} + +func TestTableColumnOrderEmptyKeepsLexicographic(t *testing.T) { + // Regression: empty / nil TableColumnOrder must produce the same + // output as before the feature shipped. + log, stdout, _ := makeLogger(t, clitr.Config{}) + + log.WithMetadata([]loglayer.Metadata{ + {"z": 1, "a": 2, "m": 3}, + }).Info("ord:") + + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if len(lines) < 2 { + t.Fatalf("expected at least 2 lines, got: %q", stdout.String()) + } + header := lines[1] + wantOrder := []string{"A", "M", "Z"} + idx := 0 + for _, want := range wantOrder { + hit := strings.Index(header[idx:], want) + if hit < 0 { + t.Fatalf("header missing %q at-or-after position %d in %q", want, idx, header) + } + idx += hit + len(want) + } +} + +func TestTableColumnOrderSkipsMissingPinnedKeys(t *testing.T) { + // A pinned key that doesn't appear in any row is silently skipped. + log, stdout, _ := makeLogger(t, clitr.Config{ + TableColumnOrder: []string{"package", "doesnotexist", "changeset"}, + }) + + log.WithMetadata([]loglayer.Metadata{ + {"package": "transports/foo", "changeset": "abc"}, + }).Info("status:") + + got := stdout.String() + if strings.Contains(strings.ToUpper(got), "DOESNOTEXIST") { + t.Errorf("missing pinned key leaked into output: %q", got) + } + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + header := lines[1] + pkgAt := strings.Index(header, "PACKAGE") + csAt := strings.Index(header, "CHANGESET") + if pkgAt < 0 || csAt < 0 || pkgAt > csAt { + t.Errorf("PACKAGE should precede CHANGESET; got: %q", header) + } +} + +func TestTableColumnOrderUnpinnedKeysSortLexicographicallyAfter(t *testing.T) { + // Keys NOT named in TableColumnOrder sort lexicographically after + // the pinned ones. + log, stdout, _ := makeLogger(t, clitr.Config{ + TableColumnOrder: []string{"package"}, + }) + + log.WithMetadata([]loglayer.Metadata{ + {"package": "transports/foo", "z": 1, "a": 2, "m": 3}, + }).Info("ord:") + + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + header := lines[1] + wantOrder := []string{"PACKAGE", "A", "M", "Z"} + idx := 0 + for _, want := range wantOrder { + hit := strings.Index(header[idx:], want) + if hit < 0 { + t.Fatalf("header missing %q at-or-after position %d in %q", want, idx, header) + } + idx += hit + len(want) + } +} + func TestTableRenderingTakesPrecedenceOverShowFields(t *testing.T) { // When ShowFields is true AND metadata is a table-shaped slice, // the table renderer wins. Logfmt is dropped for that entry.