Skip to main content

LogFormatter

LogFormatter renders a LogRecord into text; its partner, the LogWriter, routes that text to a destination. You rarely name a formatter directly — you hand a formatter/writer pair to log.writer, and the two built-ins below cover the common cases. Implement the trait yourself when your pipeline expects a shape neither produces.

Built-in Formatters​

Two singletons cover the usual output formats — both safe to share across threads, with TextLogFormatter caching its timestamp prefix for the current second:

FormatterOutput
TextLogFormatterHuman-readable — 2026-07-29T10:00:00.000Z INFO [Svc.doWork:42] message {key=val}
JsonLogFormatterOTLP-compatible JSON — {"timeUnixNano":"...","severityNumber":9,"severityText":"INFO","body":{"stringValue":"message"},"attributes":[...]}

Both render the record's timestamp, severity, source location (from the code.* attributes), message body, and an attached throwable's stack trace. They differ in what else they carry: TextLogFormatter prints the remaining attributes as a compact {key=value} list and drops the trace and span IDs, while JsonLogFormatter emits every attribute — including the four code.* entries — plus traceId and spanId when the record was written inside a span.

Example Usage​

Choosing a formatter is choosing who reads your logs. In development that's you, so pick TextLogFormatter — one aligned line per record, easy to scan in a terminal. In production it's a log collector, so pick JsonLogFormatter — every attribute stays a separate field the collector can index and query. Select it once at startup, alongside a LogWriter for the destination:

import zio.blocks.telemetry._

val isDevelopment = sys.env.get("ENV").forall(_ != "production")

if (isDevelopment) log.writer(TextLogFormatter, StdoutWriter) // readable in a terminal
else log.writer(JsonLogFormatter, StdoutWriter) // queryable by a collector

log.info("server ready", "port" -> 8080L)

You can also register both — log.writer is additive, so each call adds another channel and every record goes to all of them. That's how you keep readable console output while also emitting machine-readable JSON:

import zio.blocks.telemetry._

log.writer(TextLogFormatter, StdoutWriter) // for the developer watching the terminal
log.writer(JsonLogFormatter, StderrWriter) // for the collector tailing stderr

log.info("server ready", "port" -> 8080L) // written to both channels

Custom Formatter​

Sometimes neither built-in shape fits — an existing ingest pipeline expects logfmt, or a report wants CSV. Implement the trait yourself and pass your formatter to log.writer like any built-in. Two methods are abstract, but only one of them is yours to write:

trait LogFormatter {
def format(
sb: StringBuilder, timestampNanos: Long, severity: Severity, severityText: String,
body: String, builder: Attributes.AttributesBuilder,
traceIdHi: Long, traceIdLo: Long, spanId: Long, traceFlags: Byte,
throwable: Option[Throwable]
): Unit

def formatRecord(sb: StringBuilder, record: LogRecord): Unit
}

formatRecord is the one that runs: a channel registered with log.writer renders each finished LogRecord through it. Put your format there.

format serves a lower-allocation path that skips building a LogRecord, and the library uses it only for its own default console output — no formatter you register ever reaches it. You still have to define it to satisfy the trait, so mirror the scalar fields and move on; it cannot render attributes anyway, since the accessors for reading them off the builder are internal.

Both methods receive a fresh, empty StringBuilder from the caller, which writes it out and discards it. Append your output and return — the caller handles writing.

A CSV formatter — three comma-separated fields in formatRecord, with format mirroring them:

import zio.blocks.telemetry._

object CsvFormatter extends LogFormatter {
def formatRecord(sb: StringBuilder, record: LogRecord): Unit =
sb.append(record.timestampNanos).append(',').append(record.severityText).append(',').append(record.body.value)

def format(
sb: StringBuilder, timestampNanos: Long, severity: Severity, severityText: String,
body: String, builder: Attributes.AttributesBuilder,
traceIdHi: Long, traceIdLo: Long, spanId: Long, traceFlags: Byte,
throwable: Option[Throwable]
): Unit = sb.append(timestampNanos).append(',').append(severityText).append(',').append(body)
}

log.writer(CsvFormatter, StdoutWriter)
log.info("order placed")
// 2026-07-31T13:23:25.137Z INFO [Main$.main:20] order placed <- default console output
// 1785504205137002324,INFO,order placed <- your channel

Two lines, because log.writer adds a channel rather than replacing one, and the default console output is still registered.

To render attributes too, pass an AttributeVisitor to record.attributes.accept — it fires once per attribute with the raw unboxed value, so nothing is boxed on the way out. Only the four scalar methods are abstract; the seq variants default to no-ops. Source location arrives among those attributes as code.filepath, code.namespace, code.function, and code.lineno. Decide which convention you want: TextLogFormatter lifts them into its [Svc.doWork:42] prefix and then skips those keys, while JsonLogFormatter emits them as ordinary attributes. Skip any key starting with code. if you render the location separately.