Backends
Loggable.json produces a JsonString, but how that reaches the log output depends on the backend — this is the one
choice that materially affects the result:
| Backend | How structured values are emitted | Nested JSON? |
|---|---|---|
logback |
one logstash marker per record that writes each value as raw JSON | Yes — objects/arrays come out as genuine nested JSON |
log4j2 |
a MapMessage argument on the log call (no MDC) |
Yes with JsonTemplateLayout in object mode — our MapMessage renders its values as raw JSON |
slf4j |
slf4j 2.x fluent addKeyValue |
Depends on the provider you bind; typically strings |
console |
writes the JSON line itself | Yes — no logging framework underneath |
The same log.info("user created", User(1, "John")) (with User derives Loggable) comes out as:
// logback (LogstashEncoder) — user is a nested object alongside the message
{"message":"user created: user -> (id -> (1), name -> (John))","user":{"id":1,"name":"John"}}
// slf4j — the same value, escaped, because its key-value pairs are stringly-typed
{"message":"user created: user -> (id -> (1), name -> (John))","user":"{\"id\":1,\"name\":\"John\"}"}log4j2 also emits user as a genuine nested object, but its JsonTemplateLayout reaches the text and the values
through the same resolver, so a template has to ask for both explicitly — see
the log4j2 template below. The difference between backends is whether user is a queryable
object or an opaque string. All of them share the same Logging.create API — you only swap the
import logging4s.<backend>.<Backend>Instances.given.
For log4j2, use an event template that renders the message twice — once stringified for humans, once in object mode
for the structured values:
{
"message": {"$resolver": "message", "stringified": true},
"data": {"$resolver": "message"}
}A template with only {"$resolver": "message"} emits the values and drops the human-readable text; that is the
resolver's object mode, not a logging4s limitation. This exact template is pinned by a test against a real
JsonTemplateLayout.
How structured values reach each backend
Loggable.json produces a pre-rendered JSON string. To appear as a real nested object it must be spliced into the
output raw (not re-escaped as a string), which each backend handles differently:
- logback attaches one logstash marker per record.
LogstashEncoderasks it to write its fields, and it splices each value's JSON in raw. The JSON is rendered only then, so an appender that prints text alone (aPatternLayout) never pays for it, and however many encoders write the record, it is rendered once.%markerin a pattern shows the values askey=jsonpairs. - log4j2 puts them into a
MapMessage.MapMessageis aMultiformatMessage, and we render its JSON form with raw values, so aJsonTemplateLayoutmessageresolver in object mode (its default,stringified: false) splices them as real nested JSON — no custom resolver needed. - slf4j hands them to
addKeyValue; slf4j is only a facade, so rendering is up to whichever provider you bind, and itsKeyValuePairs have no raw-JSON notion — expect escaped strings. A value named after an envelope field (level,message, …) is suffixed (level_2), as on every other backend. - console builds the whole line itself, so there is nothing to splice.
We never touch your logback.xml / log4j2.xml: values are only attached to the event and your encoder/layout
decides what to render. The nested-JSON guarantees assume a JSON layout (LogstashEncoder / JsonTemplateLayout) — a
plain PatternLayout prints the text message and ignores the structured attachment.
logging4s-logback also ships an opt-in Logging4sEncoder that writes the JSON itself — no LogstashEncoder
round-trip, no Jackson — smaller and faster for the fixed shape it emits (@timestamp, level, logger, thread,
message, your values as raw nested JSON, MDC, slf4j 2.x fluent key-value pairs, stack_trace). Point your appender
at it in logback.xml:
<encoder class="logging4s.logback.Logging4sEncoder"/>It reads logging4s values, the MDC and the event's own key-value pairs, so plain
logger.atInfo().addKeyValue("request_id", id).log("event") from elsewhere in your application keeps its structured
field. A line never carries a key twice: an MDC or key-value entry with the same key as a logging4s value is dropped,
since the value is the more specific of the two, and one named after an envelope field (level, message, …) gets
the first free suffix (level_2) rather than shadowing it. An MDC entry or key-value pair without a value is written
as null; any other key-value pair is written as its toString, quoted. It does not understand third-party
logstash markers — if a dependency attaches those, stay on LogstashEncoder.
The envelope is configurable from the same XML — rename any field with timestampField, levelField, loggerField,
threadField, messageField or stackTraceField, and leave parts out with includeThread, includeMdc or
includeKeyValuePairs (all true by default). A logging4s value that takes a renamed field's name is suffixed, as
with the default names:
<encoder class="logging4s.logback.Logging4sEncoder">
<timestampField>time</timestampField>
<messageField>msg</messageField>
<includeThread>false</includeThread>
</encoder>If you'd rather keep LogstashEncoder (or any other), nothing changes — the structured values are attached both ways.
Standalone console
logging4s-console needs no logging framework at all — it writes structured JSON (or colored plain text) straight to
stdout/stderr, which fits containers and 12-factor apps. It's configured via HOCON under logging4s.console — override
it in your application.conf, or with LOGGING4S_CONSOLE_* environment variables:
logging4s.console {
level = "info" # off | error | warn | info | debug | trace
format = "json" # json | plain
color = "auto" # auto (terminal only) | on | off — plain format
stream = "stdout" # stdout | stderr
max-stack-trace-lines = -1 # -1 keeps the full trace
# per-logger thresholds: exact name or dot-segment prefix, most specific wins
levels {
"io.netty" = "warn"
"io.grpc" = "off" # silenced entirely
com.acme.Service = "debug"
}
}auto colors only when stdout is an interactive terminal. On JDK 22 and later System.console() also exists when the
output is redirected, so auto additionally asks the console whether it is a terminal — piped and container logs
stay free of ANSI codes. The question is about the process's terminal rather than the configured stream, so with
stream = "stderr" redirected to a file while stdout stays on a terminal, set color = "off".
A logger's threshold is resolved once, from the ConsoleConfig in scope when the logger is created, and the default
configuration is read once per process — there is no runtime reload. To change levels while running, create loggers
under a new given ConsoleConfig. A value the backend does not recognise — level = "warning", say — is not ignored:
creating the first console logger throws an IllegalArgumentException naming it, so a typo surfaces at startup.
off silences a logger completely, at the root or per logger. It is a threshold, not a level — Level still has
exactly the five levels you can log at, and the console config holds a Threshold (Threshold.Off or
Threshold.At(level)) so that no backend has to answer the meaningless question of what it means to write a record
at level "off". The other backends need nothing here: their enabled delegates to logback / log4j2 / your slf4j
provider, each of which already has OFF in its own configuration.
import logging4s.console.ConsoleInstances.given
val log = Logging.create[IO]("Main") // structured JSON to stdout, no logback.xml