Problem
The cli transport's sanitize.Message strips \n from log message bodies (defense against log injection from untrusted input). This means callers can't emit a single multi-line message through log.Info / log.Error:
log.Error("not inside a git repository: %s\n\nEither run from a git checkout, or pass --owner/--repo explicitly", dir)
Renders as one collapsed line in the cli transport because the embedded \ns are stripped.
The injection protection is the right default (untrusted user input is the common case), but multi-line messages whose content is fully application-controlled (error+hint blocks, formatted notices, structured one-shot reports) have no clean way through today.
Proposal
Add a typed wrapper at the loglayer core that opts a single message out of newline sanitization:
package loglayer
// Multiline marks lines as a single application-controlled multi-line
// message. Each arg becomes one line; empty strings produce blank lines.
// Transports that sanitize newlines for log-injection safety pass the
// joined content through verbatim when they receive a Multiline value.
//
// Multiline is opt-in per-call: the rest of the program's log output
// keeps its newline sanitization. Use Multiline only with content the
// caller controls (string literals, error messages built from trusted
// data); never wrap raw user input in Multiline.
func Multiline(lines ...string) MultilineMessage {
return MultilineMessage(strings.Join(lines, "\n"))
}
// MultilineMessage is the value type produced by Multiline. Transports
// that handle it specially type-switch on this. Ordinary string-aware
// transports (encoders that escape newlines, like JSON) treat it via
// its String method, identical to a regular string.
type MultilineMessage string
func (m MultilineMessage) String() string { return string(m) }
Caller usage:
log.Error(loglayer.Multiline(
"not inside a git repository: " + dir,
"",
"Either run `monorel init` from inside an existing git checkout (or run `git init` first), or pass --owner/--repo explicitly:",
"",
" monorel init --owner=<your-org> --repo=<your-repo>",
))
Output through the cli transport:
error: not inside a git repository: /tmp/smoke
Either run `monorel init` from inside an existing git checkout (or run `git init` first), or pass --owner/--repo explicitly:
monorel init --owner=<your-org> --repo=<your-repo>
Level prefix and color apply to the first line only (the cli transport's fmt.Fprintln(writer, body) writes the multi-line body atomically and the prefix is concatenated up-front). Subsequent lines render unprefixed, which is the desired shape for error+hint blocks.
Implementation sketch
-
Main module (go.loglayer.dev): add the Multiline function and MultilineMessage type. Place in a new multiline.go or alongside Lazy if there's a natural neighbor.
-
transports/cli: in the message-sanitization loop (currently sanitizeMessages), type-switch on loglayer.MultilineMessage and pass it through verbatim:
func sanitizeMessages(in []any) []any {
out := make([]any, len(in))
for i, m := range in {
switch v := m.(type) {
case loglayer.MultilineMessage:
out[i] = string(v) // application-controlled; trust the caller
case string:
out[i] = sanitize.Message(v)
default:
out[i] = m
}
}
return out
}
-
transports/pretty: same one-line type-switch addition. Pretty already preserves intentional newlines in some metadata renders, but the message body goes through the same sanitizer; the multi-line case should be identical to cli.
-
transports/structured / wrapper transports (zap, zerolog, slog, ...): no change needed. Their encoders escape newlines as part of JSON / structured output, which is the right behavior regardless. MultilineMessage's String() method ensures fmt.Sprint and fmt.Fprintln see a normal string.
-
Docs:
- Add a
Multiline section to the relevant logging-api page (or wherever Lazy lives).
- Add an example in
transports/cli's page showing the use case.
- The
cheatsheet.md quick-reference grows one row.
-
example_test.go in main and in transports/cli (godoc Example coverage per the project's godoc-examples rule).
Out of scope
- A
log.Block(level, body) API that does continuation indent on subsequent lines. That's a fancier render and a larger API addition; it can come later if there's demand.
- A whole-transport "preserve newlines" config flag. Per-call opt-in via
Multiline is more honest about which content is application-controlled.
Caller migration in monorel
The motivating use case is monorel init's actionable error when run outside a git repo or without an origin remote. Today (per disaresta-org/monorel#39) it returns a multi-line error and main.go's stderr printer handles it. Once Multiline lands, the call site becomes:
log.Error(loglayer.Multiline(
"not inside a git repository: " + dir,
"",
"Either run `monorel init` from inside an existing git checkout (or run `git init` first), or pass --owner/--repo explicitly:",
"",
" monorel init --owner=<your-org> --repo=<your-repo>",
))
return ErrExit(1)
Color, level prefix, and --color=never honored for free.
Problem
The cli transport's
sanitize.Messagestrips\nfrom log message bodies (defense against log injection from untrusted input). This means callers can't emit a single multi-line message throughlog.Info/log.Error:Renders as one collapsed line in the cli transport because the embedded
\ns are stripped.The injection protection is the right default (untrusted user input is the common case), but multi-line messages whose content is fully application-controlled (error+hint blocks, formatted notices, structured one-shot reports) have no clean way through today.
Proposal
Add a typed wrapper at the loglayer core that opts a single message out of newline sanitization:
Caller usage:
Output through the cli transport:
Level prefix and color apply to the first line only (the cli transport's
fmt.Fprintln(writer, body)writes the multi-line body atomically and the prefix is concatenated up-front). Subsequent lines render unprefixed, which is the desired shape for error+hint blocks.Implementation sketch
Main module (
go.loglayer.dev): add theMultilinefunction andMultilineMessagetype. Place in a newmultiline.goor alongsideLazyif there's a natural neighbor.transports/cli: in the message-sanitization loop (currentlysanitizeMessages), type-switch onloglayer.MultilineMessageand pass it through verbatim:transports/pretty: same one-line type-switch addition. Pretty already preserves intentional newlines in some metadata renders, but the message body goes through the same sanitizer; the multi-line case should be identical to cli.transports/structured/ wrapper transports (zap, zerolog, slog, ...): no change needed. Their encoders escape newlines as part of JSON / structured output, which is the right behavior regardless.MultilineMessage'sString()method ensuresfmt.Sprintandfmt.Fprintlnsee a normal string.Docs:
Multilinesection to the relevant logging-api page (or whereverLazylives).transports/cli's page showing the use case.cheatsheet.mdquick-reference grows one row.example_test.goin main and intransports/cli(godoc Example coverage per the project's godoc-examples rule).Out of scope
log.Block(level, body)API that does continuation indent on subsequent lines. That's a fancier render and a larger API addition; it can come later if there's demand.Multilineis more honest about which content is application-controlled.Caller migration in
monorelThe motivating use case is
monorel init's actionable error when run outside a git repo or without anoriginremote. Today (perdisaresta-org/monorel#39) it returns a multi-lineerrorand main.go's stderr printer handles it. OnceMultilinelands, the call site becomes:Color, level prefix, and
--color=neverhonored for free.