Skip to content

Add loglayer.Multiline for application-controlled multi-line messages #72

@theogravity

Description

@theogravity

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

  1. 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.

  2. 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
    }
  3. 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.

  4. 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.

  5. 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.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions