Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
bc8863a
docs: add Multiline message design spec
theogravity May 3, 2026
94145e2
docs: revise Multiline spec after code review
theogravity May 3, 2026
92f8285
docs: extend Multiline spec with JoinPrefixAndMessages generalization
theogravity May 3, 2026
0f7c7b3
docs: add Multiline implementation plan
theogravity May 3, 2026
42c5721
feat: add MultilineMessage skeleton (constructor + Lines)
theogravity May 3, 2026
4962d52
feat: implement MultilineMessage.String() (Stringer)
theogravity May 3, 2026
e117415
feat: format non-string Multiline args via fmt.Sprintf("%v", v)
theogravity May 3, 2026
a558dcb
feat: flatten nested *MultilineMessage at construction
theogravity May 3, 2026
566a9cd
feat: split each Multiline arg on "\n" at construction
theogravity May 3, 2026
61f36ef
feat: MultilineMessage.MarshalJSON serializes joined string
theogravity May 3, 2026
d0c411d
test: pin that *MultilineMessage does not implement error
theogravity May 3, 2026
78e267b
docs: update Multiline GoDoc to match final constructor shape
theogravity May 3, 2026
a5ced23
feat(transport): add AssembleMessage helper
theogravity May 3, 2026
f604823
test(transport): adversarial smuggling cases for AssembleMessage
theogravity May 3, 2026
8169d7a
feat(transport): JoinPrefixAndMessages handles *MultilineMessage
theogravity May 3, 2026
46d0f20
fix(transport): JoinPrefixAndMessages folds prefix for non-string args
theogravity May 3, 2026
e06e773
feat(transports/cli): adopt AssembleMessage for multi-line support
theogravity May 3, 2026
007ebee
test(transports/cli): regression-guard, mixed, prefix, empty cases
theogravity May 3, 2026
9274f0e
docs(transports/cli): refresh stale sanitizeMessages comment reference
theogravity May 3, 2026
dbb2f7d
feat(transports/pretty): adopt AssembleMessage for multi-line support
theogravity May 3, 2026
c3479ee
feat(transports/console): MessageField mode honors Multiline
theogravity May 3, 2026
431c07a
feat(transports/console): default (logfmt) mode honors Multiline
theogravity May 3, 2026
c08fb05
chore(transports/console): use maps.Copy in MessageField branch
theogravity May 3, 2026
11bc313
test(transport/transporttest): Multiline + prefix contract scenarios
theogravity May 3, 2026
ed849e1
docs: add ExampleMultiline to main module example_test.go
theogravity May 3, 2026
86301b9
docs(transports/cli): add Multiline Example for godoc
theogravity May 3, 2026
8d0ada2
docs: add logging-api/multiline.md page
theogravity May 3, 2026
51fbdc1
docs: surface Multiline in cheatsheet, sidebar, llms.txt, llms-full.txt
theogravity May 3, 2026
9822deb
docs(whats-new): add May 2 2026 entry for Multiline and prefix fix
theogravity May 3, 2026
a604efa
docs(plugins): note for plugin authors on preserving Multiline
theogravity May 3, 2026
a0cf4dd
chore: add changeset for loglayer.Multiline
theogravity May 3, 2026
3d21453
fix(transport): guard JoinPrefixAndMessages against empty Multiline
theogravity May 3, 2026
2d0ba1f
refactor: drop loglayer.NewMultilineMessage; use Multiline() in trans…
theogravity May 3, 2026
898d05d
refactor(multiline): drop dead empty-string special case
theogravity May 3, 2026
5d2b1fb
test+docs: pin Multiline edge cases and clarify nil handling
theogravity May 3, 2026
678df59
docs: clarify Multiline scope; cross-link from cli/pretty/console pages
theogravity May 3, 2026
fbdee69
docs: relocate Multiline plugin-interaction notes
theogravity May 3, 2026
553e97d
docs: add Log Sanitization reference page
theogravity May 3, 2026
6c0685e
docs: move Security sidebar group to the bottom
theogravity May 3, 2026
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
21 changes: 21 additions & 0 deletions .changeset/multiline-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"go.loglayer.dev": minor
"transports/cli": minor
"transports/pretty": minor
"transports/console": minor
---

Add `loglayer.Multiline(lines ...any)` for authoring multi-line message
content that survives terminal-renderer sanitization. The wrapper is
honored only as a positional message argument; values placed inside
`WithFields(...)` or `WithMetadata(...)` are still sanitized to a single
line in terminal transports (JSON sinks serialize via `MarshalJSON` to
the joined string).

Also fixes a pre-existing bug in `transport.JoinPrefixAndMessages`
where a `WithPrefix` value was silently dropped when `Messages[0]`
was not a string (e.g. `log.WithPrefix("X").Info(42)` lost the
prefix). The prefix now folds in front of the `%v`-formatted first
message.

See https://go.loglayer.dev/logging-api/multiline.
7 changes: 7 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ gtag('config', '${gaMeasurementId}');`,
{ text: 'Child Loggers', link: '/logging-api/child-loggers' },
{ text: 'Groups', link: '/logging-api/groups' },
{ text: 'Thread Safety', link: '/logging-api/thread-safety' },
{ text: 'Multi-line messages', link: '/logging-api/multiline' },
{ text: 'Raw Logging', link: '/logging-api/raw' },
{ text: 'Mocking', link: '/logging-api/mocking' },
],
Expand Down Expand Up @@ -212,6 +213,12 @@ gtag('config', '${gaMeasurementId}');`,
{ text: 'slog Handler', link: '/integrations/sloghandler' },
],
},
{
text: 'Security',
items: [
{ text: 'Log Sanitization', link: '/log-sanitization' },
],
},
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/loglayer/loglayer-go' },
Expand Down
12 changes: 12 additions & 0 deletions docs/src/cheatsheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ log = log.WithFields(loglayer.Fields{

`loglayer.Lazy(fn)` wraps a `WithFields` value that runs at dispatch time, after the level filter, on every emission. A panic substitutes `loglayer.LazyEvalError`. See [Lazy Evaluation](/logging-api/lazy-evaluation).

## Multi-line Messages

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

`loglayer.Multiline(lines ...any)` wraps multiple lines so the cli, pretty, and console transports render them on separate rows. Each authored line is still individually sanitized; bare `\n` in plain strings is still stripped. See [Multi-line messages](/logging-api/multiline).

## Errors

```go
Expand Down
113 changes: 113 additions & 0 deletions docs/src/log-sanitization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
title: Log Sanitization
description: "Where loglayer-go sanitizes user-controlled strings, what gets stripped, and which transports skip it"
---

# Log Sanitization

Some loglayer-go transports strip control characters from user-controlled strings before writing them. This page documents what's sanitized, where, and why; what isn't sanitized and why that's still safe; and the one developer-issued opt-in that permits authored multi-line content.

If you're writing a custom transport, the [For transport authors](#for-transport-authors) section at the bottom is the part you want.

## Threat model

Three attack classes are in scope:

1. **Log forging.** Untrusted input containing `\n` could write fake follow-up log lines that look like they came from your app. Example: a username `"alice\n2026-01-01 ERROR root: privilege escalation"` printed without sanitization would forge an alert-shaped log line.
2. **Terminal escape smuggling.** Untrusted input containing ANSI ESC (`\x1b`) could inject color codes, move the cursor, clear the screen, or exploit terminal-emulator vulnerabilities. Example: `\x1b]0;evil\x07` rewrites the terminal title; `\x1b[2J\x1b[H` clears the screen.
3. **Trojan Source / hidden content.** Bidi-control characters (U+202E "right-to-left override") visually reorder displayed text without changing the byte sequence; zero-width joiners and zero-width spaces (U+200B–U+200D, U+FEFF) hide content inside what looks like a single token. Example: `"admin‮⁦// safe"` displays as `"admin// safe"` while the real content reads admin+safe-comment-marker.

The defense is `utils/sanitize.Message`, applied at the rendering boundary in transports that target a human terminal. It drops every rune for which `unicode.IsPrint` returns false (with `\t` permitted as the only exception, since terminals interpret tab as column alignment).

## Where sanitization runs

| Site | What it sanitizes | When it fires |
|---|---|---|
| **`cli`** message body | each authored line of the message | every log call |
| **`cli`** user prefix | `WithPrefix(...)` value | when a prefix is set |
| **`cli`** level-prefix overrides | `Config.LevelPrefix` values | once at construction |
| **`cli`** logfmt field values | `fmt.Sprintf("%v", v)` per value | when `ShowFields: true` |
| **`cli`** table cells | `fmt.Sprint(v)` per cell | when metadata is slice-of-map |
| **`pretty`** message body | each authored line of the message | every log call |
| **`console`** message body | each authored line of the message | every log call |
| **`integrations/loghttp`** request fields | `RequestID`, `Method`, `Path`, recovered panic value | every request log emission |

The shared helper for sanitizing message content is [`transport.AssembleMessage(messages []any, sanitize func(string) string) string`](/transports/creating-transports). It applies the sanitize function per element while preserving line boundaries inside `*loglayer.MultilineMessage` values.

## What's stripped

The default `sanitize.Message` drops:

- ANSI ESC (`\x1b`)
- CR (`\r`)
- LF (`\n`): except inside an authored [`loglayer.Multiline`](/logging-api/multiline) value
- C0/C1 control chars (`\x00`–`\x1f`, `\x7f`–`\x9f`)
- Bidi controls (U+202A–U+202E, U+2066–U+2069, etc.)
- Zero-width joiners and spaces (U+200B–U+200D, U+FEFF)
- Other Cf-category format chars

What's preserved:

- All printable Unicode (ASCII letters / digits / punctuation, accented characters, CJK, emoji)
- Tab (`\t`)

## What does NOT get sanitized

`structured` and every wrapper transport (`zerolog`, `zap`, `slog`, `logrus`, `charmlog`, `phuslu`, `sentry`, `otellog`, `gcplogging`, `http`, `datadog`, `testing`) **do not** call `sanitize.Message`. They rely on the JSON encoder downstream to escape control bytes:

```
"\n" → "\\n"
"\x1b" → ""
"‮" → "‮"
```

This is safe for the JSON-shaped sinks because the wire output is meant for log pipelines, log aggregators, and tools like `jq` that interpret JSON-encoded escapes as text. None of them re-emit the raw bytes to a TTY.

::: warning Don't `cat` JSON wrapper-transport output to a TTY without escaping
The `` in JSON is text, but if you pipe wrapper-transport output through a tool that *does* interpret JSON escapes back to bytes (e.g., a homemade pretty-printer that calls `json.Unmarshal` and prints raw strings), you reintroduce the smuggling vector. Use `jq -r .msg` carefully on untrusted log content; prefer `jq` without `-r` (which keeps the JSON escaping) for safety.
:::

Field and metadata values reaching wrapper transports are similarly unsanitized in code; the JSON encoder is the only defense. If your wrapper-transport output flows into a non-JSON sink, audit that path.

## Opting in to authored multi-line content

[`loglayer.Multiline(lines ...any)`](/logging-api/multiline) is the developer's opt-in to permit `\n` *between* authored elements while keeping per-line sanitization for everything else. Each line is still sanitized for ANSI / CR / bidi / ZWSP individually; only the boundaries between elements survive.

```go
// One log call rendered across three lines on cli/pretty/console:
log.Info(loglayer.Multiline(
"Configuration:",
" port: 8080",
" host: ::1",
))
```

A bare string with `\n` (no wrapper, no trust) still has the `\n` stripped on those transports. See [Multi-line messages](/logging-api/multiline) for the full contract.

## For transport authors

When you're writing a custom transport, the question is whether to sanitize message content yourself. Use this decision tree:

- **Are you rendering directly to a TTY (or to anything that might be tail-followed in a terminal)?** Sanitize. Use [`transport.AssembleMessage(params.Messages, sanitize.Message)`](/transports/creating-transports) to flatten the message slice with per-line sanitize built in. This handles `*loglayer.MultilineMessage` correctly so authored multi-line content survives.
- **Are you producing JSON for a log pipeline?** Don't sanitize. The encoder handles control bytes. Pre-sanitizing would mangle legitimate log content (a stack trace's `\n` should round-trip).
- **Are you wrapping an existing logger library (zerolog/zap/slog/...)?** Don't sanitize. The underlying library has its own opinions about escaping; reaching past it is invasive and often wrong. The library produces JSON or another structured format that handles escaping itself.

If you're in case 1 (terminal rendering) and your transport sanitizes message bodies, also sanitize anywhere else where user-controlled strings reach the writer:

- The `WithPrefix` value (`params.Prefix`)
- Any `Config.*Prefix` field that accepts user input loaded from environment or config files
- Field and metadata values, if your transport renders them as text (logfmt, table cells, expanded YAML)

The cli transport is the canonical reference for "every text-shaped path is sanitized." Read `transports/cli/cli.go` for the worked pattern.

## Known gaps

- **Multiline-in-fields/metadata.** [`loglayer.Multiline`](/logging-api/multiline) is honored only as a positional message argument. A `*MultilineMessage` value placed inside `WithFields(...)` or `WithMetadata(...)` collapses to one line on cli/pretty/console (terminal sinks sanitize per-value). JSON sinks serialize via `MarshalJSON` to the joined string, so no data is silently lost there.
- **Pretty's expanded YAML mode** doesn't yet honor authored `\n` inside metadata values; it sanitizes per-value like the inline mode. A future change may route through YAML's first-class multi-line scalars; for now, render the multi-line content as a message instead of as a metadata field.

## Reference

- `utils/sanitize.Message(string) string`: the shared sanitizer.
- `transport.AssembleMessage(messages []any, sanitize func(string) string) string`: per-line, Multiline-aware message assembly.
- [Multi-line messages](/logging-api/multiline): the developer-issued opt-in for authored `\n`.
- [Creating Transports](/transports/creating-transports): full transport-authoring guide.
86 changes: 86 additions & 0 deletions docs/src/logging-api/multiline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
title: Multi-line messages with loglayer.Multiline
description: "Author multi-line message content that survives the cli/pretty/console sanitizer"
---

# 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/v2"

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 line | Across 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

| Transport | `Multiline("a","b")` | `"Header:", Multiline("a","b")` |
|---|---|---|
| **cli** | `a\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 / phuslu** | underlying logger writes `"a\nb"` | underlying logger writes `"Header: a\nb"` |
| **sentry / otellog / gcplogging / http / datadog / testing** | same: `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

::: warning 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](/plugins/creating-plugins#preserving-multilinemessage-values). One built-in plugin where the wrapper is intentionally collapsed is `fmtlog`'s format-string mode; see [Format Strings](/plugins/fmtlog#interaction-with-multiline) for the workaround.

For the full picture of what gets sanitized where (across every transport), see [Log Sanitization](/log-sanitization).
19 changes: 19 additions & 0 deletions docs/src/plugins/creating-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,25 @@ loglayer.NewMessageHook("no-newlines", func(p loglayer.BeforeMessageOutParams) [
})
```

### Preserving `*MultilineMessage` values

If your plugin walks `params.Messages` and replaces elements, be careful with `*loglayer.MultilineMessage` values. The wrapper is a developer-issued token of trust that lets terminal transports preserve authored "\n" boundaries. If your hook flattens it to a `string` (e.g., via `fmt.Sprintf` or `transport.JoinMessages`), the trust signal is lost and downstream terminal sanitize strips the inner "\n".

To preserve the multi-line shape, pass `*MultilineMessage` values through unchanged, or rebuild a new wrapper at the end of your transformation:

```go
out := make([]any, 0, len(p.Messages))
for _, m := range p.Messages {
if ml, ok := m.(*loglayer.MultilineMessage); ok {
out = append(out, ml) // pass through
continue
}
out = append(out, transformString(m))
}
```

The built-in `fmtlog` plugin's format-string mode is one example where the wrapper is intentionally collapsed: `log.Info("data: %v", loglayer.Multiline(...))` runs `fmt.Sprintf` on the wrapper, which calls `String()` and yields a flat string. Document this trade-off in your plugin's GoDoc if it applies.

### `LevelHook`

```go
Expand Down
14 changes: 14 additions & 0 deletions docs/src/plugins/fmtlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ The plugin's preconditions are:

If either fails, the messages slice passes through untouched.

## Interaction with Multiline

Combining the format-string mode with [`loglayer.Multiline(...)`](/logging-api/multiline) collapses the wrapper. When `fmtlog` fires, it runs `fmt.Sprintf(format, args...)` on every argument, which resolves the `*MultilineMessage` via its `String()` method to a flat `\n`-joined string. The trust signal is then lost: downstream terminal-renderer transports treat the result as an ordinary string and strip the inner `\n`.

```go
// ❌ Trust signal lost. Renders as one line on cli/pretty/console.
log.Info("data: %v", loglayer.Multiline("a", "b"))

// ✅ Construct the wrapper with the formatted content.
log.Info(loglayer.Multiline("data:", fmt.Sprintf(" a: %s", "x"), fmt.Sprintf(" b: %s", "y")))
```

This isn't a bug in `fmtlog`; the plugin's contract is "flatten args into a format string." If you need both Sprintf semantics *and* multi-line preservation, build the lines yourself and pass them to `Multiline` directly.

## Performance

`fmtlog.New()` is a single `MessageHook`. Per-call cost when the plugin doesn't fire (single-arg call, or first arg isn't a string): one type assertion and a length check. When it does fire: one `fmt.Sprintf` call.
Expand Down
11 changes: 11 additions & 0 deletions docs/src/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ If the callback panics, the recovered placeholder `loglayer.LazyEvalError` (`"[L

Callbacks may be invoked concurrently across goroutines if the same `*LogLayer` is shared; they must be thread-safe. Plugin call-time hooks (`OnFieldsCalled`) see the raw `*LazyValue` wrapper; dispatch-time hooks (`OnBeforeDataOut`, `OnBeforeMessageOut`, `TransformLogLevel`) see the resolved value.

## Multi-line Messages

- loglayer.Multiline(lines ...any) returns a *MultilineMessage that
terminal-renderer transports (cli, pretty, console) interpret as
authored "\n" boundaries. JSON sinks and wrapper transports flatten
via Stringer/MarshalJSON. Each authored line is still sanitized
individually. v1 is messages-only; metadata/fields values still
collapse to one line in terminal renderers.
https://go.loglayer.dev/logging-api/multiline

## Fields (persistent data across all log entries)

Fields ride on every emission from the logger they're set on. `WithFields` returns a new `*LogLayer`; always assign the result.
Expand Down Expand Up @@ -1146,6 +1156,7 @@ Full module list: [`monorel.toml`](https://github.com/loglayer/loglayer-go/blob/
- [Groups](https://go.loglayer.dev/logging-api/groups)
- [Thread Safety](https://go.loglayer.dev/logging-api/thread-safety)
- [Raw Logging](https://go.loglayer.dev/logging-api/raw)
- [Multi-line messages](https://go.loglayer.dev/logging-api/multiline)
- [Mocking](https://go.loglayer.dev/logging-api/mocking)
- [For TypeScript Developers](https://go.loglayer.dev/for-typescript-developers)
- [Transport Overview](https://go.loglayer.dev/transports/)
Expand Down
Loading
Loading