Skip to content

feat: loglayer.Multiline for multi-line messages#76

Merged
theogravity merged 39 commits into
mainfrom
feat/multiline-message
May 3, 2026
Merged

feat: loglayer.Multiline for multi-line messages#76
theogravity merged 39 commits into
mainfrom
feat/multiline-message

Conversation

@theogravity
Copy link
Copy Markdown
Contributor

Summary

  • New loglayer.Multiline(lines ...any) constructor and *MultilineMessage type. Developer-issued token of trust that lets terminal transports (cli, pretty, console) preserve authored \n boundaries while keeping per-line ANSI / CR / bidi / ZWJ sanitization intact. JSON sinks and wrapper transports flatten via Stringer / MarshalJSON with no code change.
  • New transport.AssembleMessage(messages, sanitize) helper. Per-line, Multiline-aware message assembly; replaces the existing per-transport sanitize-then-join pattern in cli/pretty/console.
  • Pre-existing bug fix in transport.JoinPrefixAndMessages: WithPrefix("X").Info(42) previously silently dropped the prefix; now folds it as "X 42" via %v formatting.
  • New contract scenarios (Multiline, MultilineWithPrefix) added to transport/transporttest's RunContract. Every wrapper transport that calls RunContract now verifies authored \n boundaries survive through to the JSON output.
  • Docs:
    • New page: Multi-line messages
    • New page: Log Sanitization (cross-cutting reference for what gets sanitized where, threat model, and a decision tree for transport authors)
    • Updated cheatsheet, sidebar, llms.txt, llms-full.txt, whats-new entry, creating-plugins note, fmtlog Interaction with Multiline section
    • GoDoc Examples in main module + transports/cli

Test plan

  • bash scripts/foreach-module.sh test passes (33 modules)
  • cd docs && bun run docs:build passes
  • Two-stage final code review (spec compliance + code quality, opus model) passed
  • Reviewer manually verifies: log.Info(loglayer.Multiline("a","b")) renders multi-line on cli, pretty, console
  • Reviewer manually verifies: bare "a\nb" still collapses to one line on cli, pretty, console (regression-guard)
  • Reviewer manually verifies: log.WithPrefix("[svc]").Info(loglayer.Multiline("a","b")) folds the prefix into the first authored line

Changeset

Bumps root + cli + pretty + console at :minor. The wrapper transports (zerolog/zap/slog/logrus/charmlog/phuslu/sentry/otellog/gcplogging/http/datadog/structured/lumberjack/testing) need no code change; they pick up Multiline automatically via Stringer and will see it in their next routine release once their go.loglayer.dev requirement bumps past v2.1.0.

🤖 Generated with Claude Code

theogravity and others added 30 commits May 2, 2026 21:05
Captures the design for loglayer.Multiline(...), a developer-issued
token of trust that lets terminal-renderer transports honor authored
\n boundaries while keeping per-line sanitization intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses code-review findings:

- C1: extend JoinPrefixAndMessages to handle *MultilineMessage so
  WithPrefix + Multiline doesn't silently drop the prefix.
- I1: drop loghttp from the call-site swap list (it sanitizes fields,
  not message content).
- I2: specify how console's two-mode buildMessages reconciles with
  AssembleMessage (assemble to string, append logfmt, single-arg
  Fprintln).
- I3: define Multiline("a\nb") as splitting at construction, so
  every transport sees the same Lines() shape.
- I4: add an "Interaction with plugins" section documenting that
  fmtlog's format-string mode collapses Multiline to a flat string
  via Stringer (trust signal lost by design; not a security issue).
- I5: rephrase the Lazy analogy so readers don't expect Multiline
  inside Fields to work.
- I6: drop the inaccurate internal/lltest reference; main module
  examples use the existing in-file exampleTransport pattern.
- I7: add same-PR sub-module changesets (cli, pretty, console) so
  the feature ships atomically.
- M2: add MarshalJSON to the type for graceful Fields/Metadata
  fallback.
- M3: note that the wrapper intentionally does not implement error.
- M4: expand the test list with cross-line ANSI smuggling, bidi /
  ZWJ stripping, and the new prefix-handling cases.
- M5: replace em dashes per the project docs rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generalizes the JoinPrefixAndMessages fix beyond *MultilineMessage:
the helper today silently drops a WithPrefix value whenever Messages[0]
isn't a string, even though every caller %v-flattens the result via
JoinMessages anyway. The prefix has no semantic reason to be dropped.
The new shape folds the prefix in front of fmt.Sprintf("%v", v) for
the general non-string case, alongside the *MultilineMessage-aware
branch that lands the prefix on the first authored line.

The existing test assertion that pinned the silent-drop behavior is
rewritten. Behavior change is documented in the changeset body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
25 TDD tasks covering the core type, AssembleMessage helper,
JoinPrefixAndMessages extension, per-transport call-site swaps
(cli, pretty, console), contract-test scenario, godoc Examples,
the doc page, ancillary docs surface (cheatsheet/sidebar/llms.txt/
whats-new/creating-plugins), changeset, and final verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Initial shape of the *MultilineMessage type with a string-only
constructor. Subsequent commits extend the constructor with non-string
%v formatting, nested-wrapper flattening, "\n" splitting, String(),
and MarshalJSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JSON sinks and wrapper transports rely on the Stringer fallback to
flatten *MultilineMessage to "\n"-joined text via fmt.Sprintf("%v", v).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves the Stringer interface and matches existing JoinMessages
semantics for non-string elements.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multiline with nested message returns flattened lines rather than
stringified inner. Without flattening, terminal sinks (per-line
sanitize) and JSON sinks (Stringer) would render the same value
differently. The inner newline would survive the JSON path but vanish
from the terminal path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multiline with embedded newline splits into separate lines at
construction. CRLF input splits to lines ending with CR so the
per-line sanitize at terminal transports strips the CR and yields
the same display as plain LF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prevents silent data loss when a MultilineMessage value is placed in
Fields or Metadata and reaches a JSON sink. With no exported fields,
default Go marshaling would produce empty object. The wrapper now
produces the newline-joined string instead, consistent with String.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Negative assertion guarding against a future convenience addition
that would conflate message-content sentinels with error values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The plan didn't update the doc comment as Tasks 3-5 grew the
constructor (non-string %v formatting, nested flattening, "\n"
splitting). Comment now describes the actual normalization rules
per the design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-line sanitize-aware message assembler for terminal-style
transports. *MultilineMessage values render with authored "\n"
boundaries preserved; bare strings with embedded "\n" still strip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pin the per-line sanitize semantics: ANSI escape split across two
authored lines cannot reconstruct; CR strips inside a line; bidi
override and ZWSP strip; only the LF boundary between authored
elements survives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When Messages[0] is a *MultilineMessage, the prefix folds into the
first authored line; subsequent lines are unchanged. Without this,
log.WithPrefix("X").Info(loglayer.Multiline(...)) would silently drop
the prefix because the helper's existing fallback returns Messages
unchanged for any non-string first arg.

Adds an internal NewMultilineMessage(lines []string) constructor for
reusing an already-canonicalized slice without re-running the
"\n"-split + flatten normalization.

Also fixes pre-existing staticcheck linting failures on unicode format
characters in test file by moving them to variables defined via rune
values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing bug: when Messages[0] was not a string, the helper
returned Messages unchanged so the WithPrefix value was silently
dropped. Every caller flattens via %v downstream anyway, so the
prefix had no semantic reason to be omitted. The default branch
added in the previous commit folds the prefix in front of
fmt.Sprintf("%v", v); this commit updates the existing test that
pinned the broken behavior and adds a Stringer case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
log.Info with loglayer.Multiline now renders authored newline boundaries
on the cli transport. Single-line messages are unchanged. The private
sanitizeMessages helper goes away since AssembleMessage covers its job
directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pin the cli transport's contract around Multiline:
- bare newline string still strips (security regression-guard)
- mixed "Header:" with Multiline produces "Header: line1\nline2"
- WithPrefix folds into the first authored line; later lines unchanged
- Multiline with zero args emits no output (matches log.Info(""))

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 12 removed the sanitizeMessages helper but left a comment in
writeValue that still referred to it. Update the comment to point at
AssembleMessage, which now owns the message-side sanitization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same pattern as transports/cli: AssembleMessage replaces the
sanitize.Message with JoinMessages call so MultilineMessage values
render with authored line boundaries while bare strings still strip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Direct AssembleMessage swap: the MessageField branch of buildMessages
now produces a "\n"-joined message with per-line sanitization. JSON
encoders escape the literal "\n" to "\\n" in the wire output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-element sanitize loop with AssembleMessage so the
headline is assembled with per-line sanitization and authored "\n"
boundaries preserved. Logfmt and Stringify suffixes attach to the
assembled headline as a second Fprintln arg, byte-equivalent to the
previous output for single-line messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address staticcheck mapsloop suggestion exposed by Task 16's
buildMessages refactor. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every wrapper transport that calls RunContract picks up two new cases:
- log.Info(loglayer.Multiline("line1","line2","line3")) emits a
  "line1\nline2\nline3" message via the Stringer fallback.
- log.WithPrefix("[svc]").Info(Multiline("a","b","c")) folds the
  prefix into the first authored line; later lines unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders against the existing exampleTransport (JSON-shaped, fixed
time field) so // Output is deterministic. Mirrors the existing
examples in the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Demonstrates the cli transport's authored-newline rendering. Uses
ColorNever so output is deterministic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks through the security rationale, the per-transport behavior, the
WithPrefix interaction, the v1 messages-only scope, and the plugin
interaction note (fmtlog format-string collapse).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hooks that mutate params.Messages should pass *MultilineMessage values
through unchanged, or rebuild a new wrapper. Flattening to a string
loses the trust signal and the inner "\n" gets stripped downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
theogravity and others added 9 commits May 2, 2026 22:52
Names go.loglayer.dev plus the three sub-modules with call-site swaps
(cli, pretty, console) so the feature ships atomically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
log.WithPrefix("[svc]").Info(loglayer.Multiline()) panicked with an
index-out-of-range when the helper folded the prefix into Lines()[0]
without checking the slice length. Now: if the wrapper has no authored
lines, the prefix becomes the sole line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…port

The exported helper existed solely so transport.JoinPrefixAndMessages
could rebuild a wrapper after folding a prefix into the first line,
skipping the constructor's re-normalization. But calling Multiline()
on the rebuilt slice produces a byte-equivalent wrapper since Lines()
entries are guaranteed to contain no "\n" (the constructor's split
step is a no-op on already-canonical input). Trades one extra
string-scan per prefixed-Multiline log call for a smaller public API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
strings.Split("", "\n") returns [""], so the explicit if-empty branch
in appendSplit produced the same output as the fallthrough. Removing
the special case keeps the constructor's behavior identical and one
branch shorter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add a table-driven TestMultiline_EdgeCases covering single-empty,
  trailing-empty, and interleaved-nil cases that the spec calls out
  explicitly.
- Clarify Lines() doc: the returned slice aliases internal storage,
  treat as read-only.
- Clarify AssembleMessage doc: nil elements format as "<nil>" via
  the default branch, matching JoinMessages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Messages-only in v1" wording read as if there were a separate
version axis from the module's actual semver. Replace with "Not
honored inside Fields or Metadata" — direct and self-explanatory —
on the doc page and the changeset body.

Add a tip callout to each affected transport's doc page (cli, pretty,
console) pointing at the multiline reference page so a reader landing
on a transport page learns about the wrapper at the moment they're
thinking about message formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The user-facing Multiline page kept a "Plugin interactions" section
that mixed two distinct audiences: plugin authors (general) and
fmtlog users (specific footgun). Move each to its natural home:

- creating-plugins.md already documents the general "preserve
  *MultilineMessage values when walking params.Messages" guidance.
- fmtlog.md gets a new "Interaction with Multiline" section with the
  format-string-collapse footgun and the workaround.

The Multiline page now points at both pages instead of duplicating
their content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-cutting reference for the sanitization story across every
transport: where sanitize.Message fires, what gets stripped (per-rune
unicode.IsPrint rule plus tab), what doesn't (JSON sinks + every
wrapper transport), and how transport authors should decide whether
to sanitize their own surface.

Includes a per-site inventory table (cli message body / user prefix /
level prefix / logfmt fields / table cells; pretty + console message
bodies; loghttp request fields), a threat-model section covering log
forging, terminal escape smuggling, and Trojan Source / hidden-content
attacks, and a decision tree for custom transports.

Cross-links from the Multiline page, the cli/pretty/console transport
pages (existing :::tip Multi-line callouts now reference the new page
too), and the Creating Transports authoring guide. New Security
sidebar group at the top of the sidebar contains the page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reads better as a reference / advanced topic at the end of the sidebar
rather than between Logging API and Transports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@theogravity theogravity enabled auto-merge (squash) May 3, 2026 06:40
@theogravity theogravity merged commit af748e7 into main May 3, 2026
13 checks passed
@theogravity theogravity deleted the feat/multiline-message branch May 3, 2026 06:42
theogravity added a commit that referenced this pull request May 3, 2026
monorel v0.12+ runs `go mod tidy` per sub-module as part of `pr` and
`release` to refresh go.sum entries. Every sub-module's go.mod
requires `go 1.25.0`, but the GitHub runner default is older and the
monorel CI action sets GOTOOLCHAIN=local, so the toolchain doesn't
auto-upgrade. The tidy step then fails with:

  go: go.mod requires go >= 1.25.0 (running go 1.24.13;
      GOTOOLCHAIN=local)

This bit the release-pr workflow when it ran on PR #76's merge commit
because monorel's `latest` tag had moved past v0.11.0 between PR #74's
release (v0.10.x) and PR #76's release (v0.13.0). The action version is
pinned (`@v0.11.0`), but the action invokes the binary under `latest`.

Add an `actions/setup-go@v5` step with `go-version: '1.25'` before the
monorel invocation in both release-pr.yml and release.yml. Mirrors the
setup-go pattern that ci.yml already uses for the regular CI matrix.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant