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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/cli-table-column-order.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 3 additions & 3 deletions .claude/rules/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<path>`, `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.

Expand Down Expand Up @@ -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/<name>.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 "<key>:<level>" --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/<name>.md` page).

For a brand-new transport, also see "When Adding a New Transport" above.
Expand All @@ -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.
9 changes: 6 additions & 3 deletions docs/src/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 27 additions & 1 deletion docs/src/transports/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions docs/src/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
44 changes: 33 additions & 11 deletions transports/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
//
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
100 changes: 100 additions & 0 deletions transports/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading