feat: loglayer.Multiline for multi-line messages#76
Merged
Conversation
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>
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>
2 tasks
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>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
loglayer.Multiline(lines ...any)constructor and*MultilineMessagetype. Developer-issued token of trust that lets terminal transports (cli,pretty,console) preserve authored\nboundaries while keeping per-line ANSI / CR / bidi / ZWJ sanitization intact. JSON sinks and wrapper transports flatten viaStringer/MarshalJSONwith no code change.transport.AssembleMessage(messages, sanitize)helper. Per-line, Multiline-aware message assembly; replaces the existing per-transport sanitize-then-join pattern in cli/pretty/console.transport.JoinPrefixAndMessages:WithPrefix("X").Info(42)previously silently dropped the prefix; now folds it as"X 42"via%vformatting.Multiline,MultilineWithPrefix) added totransport/transporttest'sRunContract. Every wrapper transport that callsRunContractnow verifies authored\nboundaries survive through to the JSON output.Interaction with Multilinesectiontransports/cliTest plan
bash scripts/foreach-module.sh testpasses (33 modules)cd docs && bun run docs:buildpasseslog.Info(loglayer.Multiline("a","b"))renders multi-line on cli, pretty, console"a\nb"still collapses to one line on cli, pretty, console (regression-guard)log.WithPrefix("[svc]").Info(loglayer.Multiline("a","b"))folds the prefix into the first authored lineChangeset
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 upMultilineautomatically viaStringerand will see it in their next routine release once theirgo.loglayer.devrequirement bumps past v2.1.0.🤖 Generated with Claude Code