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"    % "6.0.0",
  "org.logging4s" %% "logging4s-logback" % "6.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]:
  def 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

Recursive types derive too — trees, ASTs, mutually recursive products and recursive enums. With sumTypeDiscriminator = Some("type") every variant leads with its name — {"type":"Card","last4":"4242"} — so a log query can tell the variants apart.

Named tuples

A named tuple needs no declaration at all — it renders as an object keyed by its own field names, goes through the same keyNameStyle as a derived product, and has no arity limit:

log.info("order priced", (currency = "EUR", cents = 1999).asLogValue("price"))
// "price":{"currency":"EUR","cents":1999}

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

MaskMode.Full still reveals the length; for secrets use MaskMode.Fixed("***"), which renders the same mask whatever the value.

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.

The message must stay static text — it is the aggregation key. log.info(s"user $id created") is a compile error; pass the value as a value, or let the interpolator extract it:

log.info("user created", id.asLogValue("user_id"))
info"user created $id"

The call site is captured at compile time, so it costs a constant rather than a stack-trace walk. It is the position of the log call: a helper that logs makes every record point at the helper, unless it takes (using Position) itself.

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

An interpolated Throwable becomes the record's cause, so its stack trace is kept — error"payment failed $e" is log.error("payment failed", e). For a level known only at runtime there is log.log(level, "message", values*). Control characters inside values are escaped in the message text, so a value can never forge a second line in a plain-text log.

Fiber-local context

To attach values to everything logged while an effect runs — a request id set by a middleware — without threading a logger through every call, wrap the logger with Logging.contextual and scope the effect. The context lives in the runtime's fiber-local storage: IOLocal for cats-effect, FiberRef for ZIO, Local for Kyo.

val log = Logging.contextual(base)

ZioLoggingContext.scoped(requestId.asLogValue("request_id")) {  // any ZIO[R, E, A]
  handle(request)                                                // every record inside carries request_id
}
RuntimeStorageScope an effect with
cats-effectIOLocalIOLocalContext.make gives a LocalContext[IO]; local.scoped(values*)(io)
ZIOone global FiberRefZioLoggingContext.scoped(values*)(zio); given LocalContext[Task] / [UIO]
Kyoone global LocalKyoLoggingContext.scoped(values*)(v) for any A < S; given LocalContext[KIO]

A call-site value wins over the ambient context, which wins over the logger's own withContext. Nested scopes add up, the previous context comes back when a scope ends, and a disabled level never reads the fiber-local. The runnable ContextualLoggingExample runs two requests in parallel, each keeping its own request_id.

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

log4j2 reaches the text and the values through the same resolver, so its event template has to ask for both — a template with only {"$resolver": "message"} emits the values and drops the human-readable text. Use:

{
  "message": {"$resolver": "message", "stringified": true},
  "data": {"$resolver": "message"}
}

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). It also carries the MDC and slf4j key-value pairs, and never writes a key twice:

<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"    # off | error | warn | info | debug | trace
  format = "json"    # json | plain
  color  = "auto"    # auto (terminal only, also on JDK 22+) | on | off
  stream = "stdout"  # stdout | stderr
  max-stack-trace-lines = -1

  levels {
    "io.netty" = "warn"
    "io.grpc"  = "off"   # silenced entirely
  }
}

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")
includeValuesInMessagetrueappend the values to the message text; false keeps the message as bare static text and renders only the structured form
omitAbsentValuesfalsetrue drops None instead of rendering null, at the top level and inside derived products
sumTypeDiscriminatorNoneSome("type") makes a derived sum type's JSON lead with the variant name

Default keys: scalars key by type name (Loggable[Int] → int), date/time use time (LocalDate → date), 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>" % "6.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; context via IOLocal
logging4s-zio3.9 LTSzio.Task and zio.UIO; plain via zio.prelude.Debug; context via FiberRef
logging4s-kyo3.9 LTSkyo.Sync; plain via kyo.Render; context via kyo.Local
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 project — 3.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.

Every artifact is compiled with -release 17, so it runs on JDK 17 or later; logging4s-kyo is the exception, below.

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-RC7). 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 5.x to 6.0

Call sites compile unchanged. What moves is output in a few places where 5.x lost data or produced lines that could be forged, plus some test-toolkit and SPI signatures.

- "org.logging4s" %% "logging4s-cats" % "5.0.0"
+ "org.logging4s" %% "logging4s-cats" % "6.0.0"

New in 6.0:

From 4.x to 5.0

5.0 is a correctness release. Everyday call sites are unchanged; what moves is the output in cases where 4.x quietly produced duplicate or wrong data, plus one type-class signature.

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

New in 5.0:

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.