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" % "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"}
Loggable type classEverything 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")
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.
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}
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
MaskMode.Full still reveals the length; for secrets use MaskMode.Fixed("***"), which
renders the same mask whatever the value.
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.
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
}
| Runtime | Storage | Scope an effect with |
|---|---|---|
| cats-effect | IOLocal | IOLocalContext.make gives a LocalContext[IO]; local.scoped(values*)(io) |
| ZIO | one global FiberRef | ZioLoggingContext.scoped(values*)(zio); given LocalContext[Task] / [UIO] |
| Kyo | one global Local | KyoLoggingContext.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.
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
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
}
}
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") |
includeValuesInMessage | true | append the values to the message text; false keeps the message as bare static text and renders only the structured form |
omitAbsentValues | false | true drops None instead of rendering null, at the top level and inside derived products |
sumTypeDiscriminator | None | Some("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.
Published for Scala 3 under org.logging4s:
"org.logging4s" %% "logging4s-<module>" % "6.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; context via IOLocal |
logging4s-zio | 3.9 LTS | zio.Task and zio.UIO; plain via zio.prelude.Debug; context via FiberRef | |
logging4s-kyo | 3.9 LTS | kyo.Sync; plain via kyo.Render; context via kyo.Local | |
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.
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.
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.
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"
Throwable is the cause — error"failed $e" used to
attach e as a field and drop the stack trace. The record now gains stack_trace and the
e field is gone; pass e.asLogValue("e") to keep it as a value.RecordingLogging behaves like a backend — RecordedEvent.fields holds
the merged, normalized values; valueOf no longer prefers the context over the call site, and keys
are normalized.Map keys are suffixed (null, null_2) instead
of written twice; Logging4sEncoder no longer writes an MDC key that a value or the
envelope already uses, and LogstashEncoder lines no longer carry
"tags":["logging4s.values"].color = "auto" stays plain for redirected output on JDK 22+.LoggableEncodingConfig fields (appended last),
Loggable.isAbsent (defaults to false), MaskMode.Fixed.
logging4s-cats now depends on cats-effect for IOLocal.New in 6.0:
Logging.contextual and IOLocal /
FiberRef / kyo Local.log(level, …) for a level chosen at runtime.omitAbsentValues and sumTypeDiscriminator.MaskMode.Fixed, and instances for cats NonEmptyChain /
NonEmptySeq / Validated, zio NonEmptyChunk, plus
Delay[UIO].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"
.mask(_.password)(…).rename(_.password, "pan") used to silently unmask the value.
hide beats mask beats unembed, and FieldPolicy is now a case
class of independent settings rather than an enum of mutually exclusive cases.level, two
fields that normalize to the same key, a tuple rendered as an object, and unembed colliding with
its parent.Map keys come from the key's JSON form, so keys whose plain rendering collided
no longer collapse into one field.Float/Double render as JSON null instead of
bare NaN, which made the whole record unparseable.Logfmt quotes and escapes values; key-name styles use
Locale.ROOT; ZIO Debug plain encoders no longer corrupt values
(123 used to render as 2).Loggable.key is a def — override val key = … keeps
working.log.info(s"user $id created") no longer
compiles; pass id as a value, or use the info"…" interpolator. A message that merely
varies, or an interpolation without holes, is still fine.ConsoleConfig holds a Threshold — code that builds it writes
Threshold.At(Level.Info) instead of Level.Info, and levelFor became
thresholdFor. HOCON is unchanged apart from gaining off.New in 5.0:
off as a console threshold, at the root or per logger.includeValuesInMessage = false keeps the message as bare static text and renders
values only into the structured channel.Logging.noop[F] and
logging4s.core.testing.RecordingLogging[F], which records every event for assertions without a
logging framework.
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 four abstract members instead of twenty-one — implement
withContext / 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.