Structured logging for Scala 3 — for any backend, any effect, and any JSON library.
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.
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"}
Loggable type classEverything 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")
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
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
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 "***"
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.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).
Loggable.json produces a JSON string, but how it reaches the output depends on the backend — the one
choice that materially affects the result:
| Backend | How structured values are emitted | Nested JSON? |
|---|---|---|
logback | logstash raw-JSON markers | Yes — genuine nested objects/arrays |
log4j2 | a MapMessage argument (no MDC) | Yes with JsonTemplateLayout in object mode |
slf4j | slf4j 2.x fluent addKeyValue | Provider-dependent; typically strings |
console | writes the JSON itself to stdout/stderr | Yes — 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
}
Rendering is controlled by a single LoggableEncodingConfig (logging4s.core.config). A
default given is provided; override it once, application-wide.
| Field | Default | Effect |
|---|---|---|
jsonTupleAsArray | true | tuple/Ior JSON: [1,"a"] vs {"int":1,"string":"a"} |
mapAsObject | true | Map JSON: {"a":1} vs [["a",1]] (the 3.x shape) |
keyNameStyle | SnakeCase | applied to every key: AsIs / Snake / Kebab / Camel / Pascal |
plainTupleStyle | AsScala | tuple plain form: (1, a) / [1, a] / 1, a / {1, a} |
plainValuesStyle | Arrow | value join: k -> (v) / k=v / k: v / {k=v} |
includeSourcePosition | true | attach the call site as a source field ("OrderService.scala:42") |
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.
Published for Scala 3 under org.logging4s:
"org.logging4s" %% "logging4s-<module>" % "4.0.0"
| Kind | Module | Min. Scala | Notes |
|---|---|---|---|
| core | logging4s-core | 3.9 LTS | type classes + Logging; no backend dependency |
| backend | logging4s-logback | 3.9 LTS | logback + logstash-encoder; real nested JSON |
logging4s-log4j2 | 3.9 LTS | Log4j2 API; values as a MapMessage | |
logging4s-slf4j | 3.9 LTS | bare slf4j-api 2.x; bring your own binding | |
logging4s-console | 3.9 LTS | standalone JSON/plain to stdout; HOCON-configured | |
| runtime | logging4s-cats | 3.9 LTS | cats-effect 3; plain via cats.Show |
logging4s-zio | 3.9 LTS | zio.Task; plain via zio.prelude.Debug | |
logging4s-kyo | 3.9 LTS | kyo.Sync; plain via kyo.Render | |
logging4s-rapid | 3.9 LTS | rapid.Task | |
| json | logging4s-circe | 3.9 LTS | io.circe.Encoder |
logging4s-jsoniter | 3.9 LTS | jsoniter-scala JsonValueCodec | |
logging4s-zio-json | 3.9 LTS | zio-json JsonEncoder | |
logging4s-play-json | 3.9 LTS | play-json Writes | |
logging4s-spray-json | 3.9 LTS | spray-json JsonWriter | |
logging4s-json4s | 3.9 LTS | json4s Formats | |
logging4s-argonaut | 3.9 LTS | argonaut EncodeJson | |
logging4s-borer | 3.9 LTS | borer Encoder | |
logging4s-upickle | 3.9 LTS | upickle Writer | |
logging4s-weepickle | 3.9 LTS | weepickle From | |
logging4s-fabric | 3.9 LTS | fabric 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.
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.
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.
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):
| Config | ops/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.
A second harness measures what a suppressed record costs — a debug call under a logger set to
INFO:
| Call shape | ops/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.
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"
3.9 — every module moved from
3.3 LTS to the new 3.9 LTS, and TASTy is not forward compatible. Stay on
3.0.x until you move.Map renders as a JSON object — {"a":1} instead of the old
[["a",1]], keyed by the plain form of each key; the plain form follows
plainValuesStyle. Restore the old shape with
LoggableEncodingConfig(mapAsObject = false).given definitions need Scala 3's new syntax —
given L: [A] => (l: Loggable[A]) => Loggable[Wrapper[A]]. A Scala change rather than a
logging4s one, but the first thing you hit on 3.9.Fixed in 4.0:
info"…" built both the JSON and the plain
form of every value up front, bypassing the lazy LoggableValue added in 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"
Logging[F] has three abstract members instead of twenty — implement
emit / enabled / unit; the per-level overloads are final.Level moved to logging4s.core from logging4s.console.LoggableValue is a sealed trait and renders lazily — no copy, no
pattern matching; use withKey to rewrite a key.None and Unit render as JSON null instead of an empty
string, which used to produce invalid JSON.source field — disable with
LoggableEncodingConfig(includeSourcePosition = false).LoggableMapMessage takes Seq[(String, String)] so log4j2 field order
follows the call site.
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.x | 2.0 | Use when |
|---|---|---|
make[A: JsonEncoder: PlainEncoder]("k") | Loggable.fromEncoders[A] | You have a JSON codec — json is the codec's own output. |
| n/a | derives Loggable / Loggable.derived[A] | No codec — JSON assembled structurally by the macro. |
| n/a | Loggable.deriving[A]….derived | Derived, 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.