logging4s

Structured logging for Scala 3 — for any backend, any effect, and any JSON library.

CI status Maven Central version Scala 3.9 Apache 2.0 license

You describe how your types render with a Loggable[A] type class, get a Logging[F[_]] for your effect type, and log values directly. Each value is rendered to JSON and attached to the log event as structured data; the message string carries a human-readable rendering of the same values.

The library is a backend-agnostic core plus thin integration modules. The logging backend (logback / log4j2 / slf4j / console), the effect type (cats-effect / ZIO / Kyo / rapid), and the JSON codec (circe / jsoniter / …) are independent modules, each wired in through a given import. core has no dependency on any of them.

Quick start

Pick a backend and an effect runtime (a JSON library is optional — derives Loggable needs none):

libraryDependencies ++= Seq(
  "org.logging4s" %% "logging4s-cats"    % "4.0.0",
  "org.logging4s" %% "logging4s-logback" % "4.0.0"
)
import cats.effect.{IO, IOApp}

import logging4s.core.{Loggable, Logging}
import logging4s.cats.CatsInstances.given        // Delay[IO], cats.Show / data bridges
import logging4s.logback.LogbackInstances.given  // the LoggingFactory

final case class User(id: Int, name: String) derives Loggable

object Main extends IOApp.Simple:
  def run: IO[Unit] =
    for
      log <- Logging.create[IO]("Main")
      _   <- log.info("user created", User(1, "John"))
    yield ()

The logback backend attaches User as a nested JSON object; the message keeps its plain rendering:

{"message":"user created: user -> (id -> (1), name -> (John))","user":{"id":1,"name":"John"},"source":"Main.scala:12","level":"INFO"}

The Loggable type class

Everything centers on one type class. A Loggable[A] is required for any value you log:

trait Loggable[A]:
  val key: ValueKey            // the field name the value logs under
  def json(a: A): JsonString   // structured form
  def plain(a: A): PlainString // human-readable form, appended to the message

There are four ways to obtain an instance — derived, fromEncoders, make, and the field-policy builder deriving. Each infers the top-level key from the type name by default, or takes an explicit key as its first argument, consistently across all four:

Loggable.derived[User]        // key "user"     Loggable.derived[User]("account")
Loggable.fromEncoders[User]   // key "user"     Loggable.fromEncoders[User]("account")
Loggable.make[User](…)        // key "user"     Loggable.make[User]("account")(…)
Loggable.deriving[User]       // key "user"     Loggable.deriving[User]("account")

Derivation

derives Loggable builds both renderings structurally from the fields — the JSON object is assembled by the macro, no JSON library involved. Products key by field name; sum types delegate to the selected variant.

final case class Point(x: Int, y: Int) derives Loggable

enum Color derives Loggable:
  case Red, Green, Blue

From an existing codec

If you already have a JSON codec for A, Loggable.fromEncoders delegates json to it — the log JSON is exactly your codec's output (its field names, its escaping), and plain comes from a PlainEncoder (e.g. bridged from cats.Show) or a structural fallback. Prefer this when a codec exists.

given io.circe.Encoder[User] = io.circe.generic.semiauto.deriveEncoder
given Loggable[User]         = Loggable.fromEncoders

By hand, or by adapting another instance

make takes the two renderings directly — json returns a JsonString (JsonString.quoted escapes a string, JsonString(…) is raw), plain a PlainString:

given Loggable[Money]  = Loggable.make(m => JsonString(m.cents.toString), m => PlainString(s"$$${m.cents / 100.0}"))
given Loggable[UserId] = Loggable[Int].contramap(_.value, "user_id")
val secret             = Loggable[String].redacted()   // always renders "***"

Per-field policies

For derived products, override individual fields with a builder. Selectors are macro-checked field references — not strings, not annotations — so they survive refactors and work on types you don't own:

given Loggable[Account] =
  Loggable.deriving[Account]
    .hide(_.password)                    // omit
    .mask(_.email)(MaskMode.KeepLast(4)) // partial mask
    .rename(_.id, "account_id")          // custom key
    .unembed(_.address)                  // splice its fields into the parent object
    .derived

Logging

Logging.create returns F[Logging[F]]; the log methods return F[Unit]. There are createTry / createEither / createUnsafe variants for non-effect code.

for
  log <- Logging.create[IO]("OrderService")
  _   <- log.info("order placed", user, order)          // any number of values
  _   <- log.error("payment failed", throwable, order)  // with a cause
  scoped = log.withContextValues(requestId.asLogValue("request_id"))
  _   <- scoped.info("handled")                         // context attached to every line
yield ()

An interpolator is available for terser call sites — import logging4s.core.syntax.logging.*, have a given Logging[F] in scope, and the key is taken from the interpolated identifier:

info"order placed: $order"   // == log.info("order placed", order.asLogValue("order"))

Values render lazily and the interpolator checks the level first, so a debug"…" under a logger set to INFO evaluates neither the interpolated expressions nor their JSON. Every record also carries the call site as a source field.

Duplicate keys resolve by specificity: a call-site value overrides a context value with the same key, and a later withContext overrides an earlier one. Only duplicates within a single call are suffixed (k, k_2).

Backends

Loggable.json produces a JSON string, but how it reaches the output depends on the backend — the one choice that materially affects the result:

BackendHow structured values are emittedNested JSON?
logbacklogstash raw-JSON markersYes — genuine nested objects/arrays
log4j2a MapMessage argument (no MDC)Yes with JsonTemplateLayout in object mode
slf4jslf4j 2.x fluent addKeyValueProvider-dependent; typically strings
consolewrites the JSON itself to stdout/stderrYes — no framework needed

The same call comes out as a nested object under logback / log4j2 / console, and as an escaped string under bare slf4j:

{"message":"user created: …","user":{"id":1,"name":"John"}}     // logback / log4j2 / console
{"message":"user created: …","user":"{\"id\":1,\"name\":\"John\"}"} // slf4j — stringly-typed

We never touch your logback.xml / log4j2.xml: values are only attached to the event and your encoder/layout decides what to render. logging4s-logback also ships an opt-in Logging4sEncoder that writes the JSON itself (no logstash round-trip, no Jackson):

<encoder class="logging4s.logback.Logging4sEncoder"/>

logging4s-console needs no logging framework at all — structured JSON (or colored plain) straight to stdout, ideal for containers. It's configured via HOCON under logging4s.console (override in application.conf or with LOGGING4S_CONSOLE_* env vars):

logging4s.console {
  level  = "info"    # error | warn | info | debug | trace
  format = "json"    # json | plain
  color  = "auto"    # auto (TTY only) | on | off
  stream = "stdout"  # stdout | stderr
  max-stack-trace-lines = -1
}

Configuration

Rendering is controlled by a single LoggableEncodingConfig (logging4s.core.config). A default given is provided; override it once, application-wide.

FieldDefaultEffect
jsonTupleAsArraytruetuple/Ior JSON: [1,"a"] vs {"int":1,"string":"a"}
mapAsObjecttrueMap JSON: {"a":1} vs [["a",1]] (the 3.x shape)
keyNameStyleSnakeCaseapplied to every key: AsIs / Snake / Kebab / Camel / Pascal
plainTupleStyleAsScalatuple plain form: (1, a) / [1, a] / 1, a / {1, a}
plainValuesStyleArrowvalue join: k -> (v) / k=v / k: v / {k=v}
includeSourcePositiontrueattach the call site as a source field ("OrderService.scala:42")

Default keys: scalars key by type name (Loggable[Int]int), date/time use time (LocalDatedate), FiniteDuration and java.time.Duration use time_ms, Throwable uses error, collections pluralize (List[Int]ints), a derived case class keys by its decapitalized name.

Modules

Published for Scala 3 under org.logging4s:

"org.logging4s" %% "logging4s-<module>" % "4.0.0"
KindModuleMin. ScalaNotes
corelogging4s-core3.9 LTStype classes + Logging; no backend dependency
backendlogging4s-logback3.9 LTSlogback + logstash-encoder; real nested JSON
logging4s-log4j23.9 LTSLog4j2 API; values as a MapMessage
logging4s-slf4j3.9 LTSbare slf4j-api 2.x; bring your own binding
logging4s-console3.9 LTSstandalone JSON/plain to stdout; HOCON-configured
runtimelogging4s-cats3.9 LTScats-effect 3; plain via cats.Show
logging4s-zio3.9 LTSzio.Task; plain via zio.prelude.Debug
logging4s-kyo3.9 LTSkyo.Sync; plain via kyo.Render
logging4s-rapid3.9 LTSrapid.Task
jsonlogging4s-circe3.9 LTSio.circe.Encoder
logging4s-jsoniter3.9 LTSjsoniter-scala JsonValueCodec
logging4s-zio-json3.9 LTSzio-json JsonEncoder
logging4s-play-json3.9 LTSplay-json Writes
logging4s-spray-json3.9 LTSspray-json JsonWriter
logging4s-json4s3.9 LTSjson4s Formats
logging4s-argonaut3.9 LTSargonaut EncodeJson
logging4s-borer3.9 LTSborer Encoder
logging4s-upickle3.9 LTSupickle Writer
logging4s-weepickle3.9 LTSweepickle From
logging4s-fabric3.9 LTSfabric Json

Each integration module exposes its givens as a named trait plus companion object, so you can also mix several into one import: object instances extends LogbackInstances with CatsInstances with CirceInstances.

Compatibility

Every module is built on the 3.9 LTS release. Scala 3 TASTy is backward but not forward compatible, so 4.x cannot be consumed from a 3.3 LTS project3.0.x is the line to stay on until your application moves to 3.9.

In exchange, the split that 3.x had is gone: logging4s-kyo and logging4s-rapid used to be pinned to a newer Scala than everything else, and now sit on the same release as core.

Within a major version, binary compatibility is checked by MiMa on every build, and versionScheme := "semver-spec" describes what the artifacts promise.

logging4s-kyo depends on a kyo release candidate (1.0.0-RC6). Until kyo reaches 1.0.0 final, that module sits outside the binary-compatibility promise the other artifacts make. Compiling it also needs JDK 25 — kyo's Frame macro runs inside the compiler and its class files target Java 25, so this is a compile-time requirement, not just a runtime one.

Benchmarks

A JMH harness logs the same mid-size event through every backend into a discarding sink. Every row is logging4s itself on a different backend/encoder — this compares our own backend choices against each other, not logging4s against other logging libraries. Measured as throughput — operations per second, higher is better (single fork, one machine — rough ballpark, run it on your own hardware):

Configops/s
log4j2 + JsonTemplateLayout (nested)~330k
log4j2 (stringified)~210k
console + fromEncoders (jsoniter)~200k
logback + Logging4sEncoder~170k
logback + LogstashEncoder~140k
console + derives~120k

Rough numbers — single fork on a loaded laptop, wide error bars, read them as orders of magnitude. The encoding path matters as much as the backend: on the same console, fromEncoders with a codec is ~1.7× the throughput of derives. The opt-in Logging4sEncoder edges past the standard LogstashEncoder — it builds the JSON in one pass with no Jackson round-trip.

The disabled-level path

A second harness measures what a suppressed record costs — a debug call under a logger set to INFO:

Call shapeops/s
interpolator, level off~791M
direct call, lazy value, level off~387M
direct call, pre-rendered value, level off (2.x behaviour)~1.5M
interpolator, level on~409k
direct call, level on~406k

In 2.x a suppressed debug still rendered every value to JSON at the call site, so it cost roughly a quarter of an actual log write. In 3.0 values render lazily (~250× cheaper) and the interpolator additionally skips the call when the level is off (another ~2×). With the level on, the guard costs nothing measurable. The two disabled rows are JIT-optimistic in absolute terms — with nothing escaping, escape analysis removes the wrapper allocations outright — so read the orders of magnitude, not the nanoseconds.

Migration

From 3.x to 4.0

4.0 breaks in exactly two places: the Scala version the artifacts are built on, and the way Map renders. No call site, type class or given import changes.

- "org.logging4s" %% "logging4s-cats" % "3.0.0"
+ "org.logging4s" %% "logging4s-cats" % "4.0.0"

Fixed in 4.0:

From 2.x to 3.0

3.0 is a breaking release. Everyday call sites — Logging.create, the per-level methods, withContext, the *Instances.given imports — are unchanged; the breaks are in what you implement and in the shape of the output.

- "org.logging4s" %% "logging4s-cats" % "2.0.1"
+ "org.logging4s" %% "logging4s-cats" % "3.0.0"

From 1.x to 2.0

2.0.0 is a breaking release, but most call sites need only small edits — Logging.create, the *Instances.given imports, LoggingContext, and the per-level methods are all source-compatible.

1. Bump the version. Coordinates are otherwise identical:

- "org.logging4s" %% "logging4s-cats" % "1.0.1"
+ "org.logging4s" %% "logging4s-cats" % "2.0.0"

2. syntax is now a package — add .all. The extension methods (asLogValue, withKey, Seq.plain) sit behind an aggregator object:

- import logging4s.core.syntax.*
+ import logging4s.core.syntax.all.*

3. The single make split into four purpose-specific constructors. In 1.x every instance was built with Loggable.make. 2.0 replaces it with four constructors — pick by where the two renderings come from:

1.x2.0Use when
make[A: JsonEncoder: PlainEncoder]("k")Loggable.fromEncoders[A]You have a JSON codec — json is the codec's own output.
n/aderives Loggable / Loggable.derived[A]No codec — JSON assembled structurally by the macro.
n/aLoggable.deriving[A]….derivedDerived, plus per-field policies (hide / mask / rename / unembed).
make[A]("k")(encode, show)Loggable.make[A](json, plain)Fully manual — you write both renderings by hand.
// 1.x — codec-backed make
given Loggable[User] = Loggable.make("user")
// 2.0 — that is exactly fromEncoders (key "user" from the type name; or fromEncoders("user"))
given Loggable[User] = Loggable.fromEncoders

// 1.x — manual make, functions returned String
given Loggable[Money] = Loggable.make("money")(m => m.cents.toString, m => s"$$${m.cents / 100.0}")
// 2.0 — same shape, functions now return the opaque JsonString / PlainString
given Loggable[Money] = Loggable.make(m => JsonString(m.cents.toString), m => PlainString(s"$$${m.cents / 100.0}"))

Every constructor takes the key as an optional first argument, or infers it from the type name.

4. Hand-written instances return opaque types. If you implement Loggable (or JsonEncoder / PlainEncoder) by hand, key / json / plain return ValueKey / JsonString / PlainString instead of raw String — wrap the values. Also rename / contramap / redacted are now final.