Skip to content

Multi-line messages with loglayer.Multiline ​

loglayer.Multiline(lines ...any) lets you author a message that renders on multiple lines through cli, pretty, and console. It's a developer-issued token of trust: the wrapper signals that the line boundaries between elements were authored by you, so the sanitizer in those transports preserves the \n between them while still stripping ANSI / control bytes inside each line.

Quickstart ​

go
import "go.loglayer.dev/v3"

log.Info(loglayer.Multiline(
    "Configuration:",
    "  port: 8080",
    "  host: ::1",
))
// Configuration:
//   port: 8080
//   host: ::1

Multiline accepts any number of arguments and treats each one as a separate authored line. Non-string arguments are formatted with fmt.Sprintf("%v", v) (Stringer is honored). Strings containing embedded \n are split at construction, so Multiline("a\nb") and Multiline("a", "b") are interchangeable.

Why bare \n doesn't work ​

If you write log.Info("Header:\n port: 8080") without the wrapper, the cli, pretty, and console transports collapse it to one line:

Header:  port: 8080

The sanitizer at those rendering boundaries strips \n from message strings to defeat two attacks:

  1. Log forging: untrusted input containing \n could write fake follow-up log lines that look like they came from your app.
  2. Terminal escape smuggling: untrusted input containing ANSI ESC, bidi overrides (Trojan Source), or zero-width joiners could inject color codes, hide content, or exploit terminal vulns.

Multiline opts you out of the line-collapsing rule for this one call, while keeping every other defense intact. Each authored line is still individually sanitized.

What's preserved, what's stripped ​

Inside one authored lineAcross authored lines
ANSI ESC: stripped\n boundary: preserved
CR: stripped
Bidi overrides (U+202E etc.): stripped
Zero-width joiners / spaces: stripped

A bare string with \n (no wrapper, no trust) still has the \n stripped. Multiline("\x1b", "[31mred") cannot reconstruct an ANSI escape across the boundary because each line is sanitized in isolation before joining.

Per-transport behavior ​

TransportMultiline("a","b")"Header:", Multiline("a","b")
clia\nb (level-colored, on the level's writer)Header: a\nb
pretty[ts] [INFO] a\nb[ts] [INFO] Header: a\nb
console{"msg":"a\nb",...} (MessageField mode) or a\nb [k=v ...] (default)analogous
structured{"msg":"a\nb",...}{"msg":"Header: a\nb",...}
zerolog / zap / slog / logrus / charmlog / phusluunderlying logger writes "a\nb"underlying logger writes "Header: a\nb"
sentry / otellog / gcplogging / http / datadog / testingsame: Stringer fallback joins with "\n"same

With a prefix ​

WithPrefix folds the prefix into the first authored line; subsequent lines are unchanged.

go
log.WithPrefix("[svc]").Info(loglayer.Multiline("a", "b"))
// [svc] a
// b

Inside fields or metadata ​

Not honored inside Fields or Metadata

Multiline only applies when it appears as a positional message argument (log.Info(loglayer.Multiline(...)), log.Error(loglayer.Multiline(...)), etc.). Inside WithFields(...) or WithMetadata(...), terminal transports (cli, pretty, console) still sanitize each value to a single line, so a Multiline value placed there gets collapsed.

JSON sinks (structured + every wrapper transport) serialize Multiline values via MarshalJSON to the \n-joined string, so no data is silently lost in those sinks.

If you need multi-line value rendering for fields specifically, file an issue describing the use case; the right shape is a separate design (probably routing through pretty's expanded-YAML mode).

Plugin authors who walk params.Messages should preserve *MultilineMessage values; see Creating plugins. One built-in plugin where the wrapper is intentionally collapsed is fmtlog's format-string mode; see Format Strings for the workaround.

For the full picture of what gets sanitized where (across every transport), see Log Sanitization.