From 10f7a99574922abda1c938ab8f7efd469aea4a94 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 2 May 2026 17:02:57 -0700 Subject: [PATCH 1/2] feat(transports/cli): pin table column order via Config.TableColumnOrder The cli transport's slice-of-map table renderer sorted columns purely lexicographically, which works for ad-hoc tables but produces a readability regression when an *identifier* column should anchor the row. monorel migrated to the cli transport and lost its hand-coded column ordering this way: `bump | changeset | package | summary` instead of `CHANGESET PACKAGE BUMP SUMMARY`. Add a `Config.TableColumnOrder []string` knob: cli.New(cli.Config{ TableColumnOrder: []string{"package", "changeset"}, }) Keys named here render in the listed order at the front of the table; remaining keys sort lexicographically and follow. The knob is additive (pin only the leading columns; rest sort). 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 prior fully-lexicographic behavior. Tests cover the four cases enumerated in #66: - Empty TableColumnOrder produces full lex output (regression cover). - Pinned keys lead in listed order regardless of row insertion order. - Pinned key absent from every row is silently dropped. - Un-pinned keys still lex-sort among themselves after the pinned ones. Resolves #66. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/cli-table-column-order.md | 10 +++ docs/src/public/llms-full.txt | 9 ++- docs/src/public/llms.txt | 2 +- docs/src/transports/cli.md | 28 +++++++- docs/src/whats-new.md | 2 + transports/cli/cli.go | 44 +++++++++--- transports/cli/cli_test.go | 100 +++++++++++++++++++++++++++ 7 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 .changeset/cli-table-column-order.md 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/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. From bacf1fbcb842fecc5d49959ed81a62e13396dad1 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 2 May 2026 17:07:36 -0700 Subject: [PATCH 2/2] docs(rules): stop instructing manual CHANGELOG.md edits The "When You Add a New Feature" rule listed a manual `## [Unreleased]` edit to the root `CHANGELOG.md`, and the "Versioning Note" claimed the repo was a single Go module that shouldn't have per-package CHANGELOGs. Both predate the monorel migration and contradict the current AGENTS.md guidance: - The repo is multi-module; every transport, plugin, and integration has its own CHANGELOG.md. - Both root and per-package CHANGELOGs are written from `.changeset/*.md` files by `monorel release` at release time. - Manually adding `[Unreleased]` entries races the monorel pipeline and produces drift. Replace the CHANGELOG step with the changeset step, and rewrite the Versioning Note to point at AGENTS.md as the authoritative source. Also fix the floor-bump instruction in the same file that referenced manual `CHANGELOG.md` edits for the same reason. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/documentation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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.