SENDLINE open source
open source / logging4s / backends

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:

jsonc
// 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:

json
{
  "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. LogstashEncoder asks 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 (a PatternLayout) never pays for it, and however many encoders write the record, it is rendered once. %marker in a pattern shows the values as key=json pairs.
  • log4j2 puts them into a MapMessage. MapMessage is a MultiformatMessage, and we render its JSON form with raw values, so a JsonTemplateLayout message resolver 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 its KeyValuePairs 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:

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:

xml
<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:

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

scala
import logging4s.console.ConsoleInstances.given

val log = Logging.create[IO]("Main")   // structured JSON to stdout, no logback.xml
built from README.md at v7.0.0 edit this page on GitHub