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 messageValueKey, JsonString and PlainString are opaque Strings. 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 (or Loggable.derived) builds both renderings structurally from the fields — each field type needs
its own Loggable, and the JSON object is assembled by the macro (no JSON library involved):
final case class Point(x: Int, y: Int) derives Loggable
enum Color derives Loggable:
case Red, Green, BlueProducts render as objects keyed by field name (run through keyNameStyle), and a case class without fields as {}.
Sum types delegate to the selected variant, and a case object or parameterless enum case renders as its name. Set
sumTypeDiscriminator = Some("type") in the configuration to have every variant lead with its
name — {"type":"Card","last4":"4242"}, and {"type":"Pending"} for a case without fields, so the field keeps one
JSON type in an index mapping — and a log query can tell Card from Cash. A variant that already has a field of
that name gets the first free suffix (type_2) for the tag instead, and a nested hierarchy is tagged once, with the
leaf's name.
A generic case class derives from its type arguments' instances: case class Box[A](value: A) derives Loggable gives
a Loggable[Box[A]] for every A that has one. Derivation reads the first parameter list only — a case class with a
second one has no Mirror and does not derive — and renders every field in it, private vals included, just as
toString does; hide one with the per-field builder.
Named tuples
A named tuple needs no declaration at all — it renders as an object keyed by its own field names, which makes it the lightest way to attach a few related fields to one record:
log.info("order priced", (currency = "EUR", cents = 1999).asLogValue("price"))
// "price":{"currency":"EUR","cents":1999}They go through the same keyNameStyle as a derived product, nest inside one another, and have no arity limit —
unlike plain tuples, whose instances stop at Tuple5.
From an existing codec
If you already have a JSON codec for A, Loggable.fromEncoders delegates json to it. The log JSON is then exactly
your codec's output — its field names, its escaping, untouched by our config. plain comes from a PlainEncoder[A]
(e.g. bridged from cats.Show) when one is in scope. Without one it is derived structurally if every field has a
Loggable of its own, and otherwise it is the codec's JSON — so the codec alone is always enough, and a field type
without a Loggable never stops fromEncoders from compiling:
import io.circe.Encoder
import io.circe.generic.semiauto.deriveEncoder
import logging4s.core.Loggable
import logging4s.json.circe.CirceInstances.given
final case class User(id: Int, name: String)
object User:
given Encoder[User] = deriveEncoder
given Loggable[User] = Loggable.fromEncodersPrefer this over derives when a codec exists: it delegates to the codec's writer instead of assembling strings, and
keeps the logged representation identical to the rest of your app. derives is for types that have no codec.
By hand
Give the two renderings directly — json returns a JsonString (use JsonString.quoted to escape a string value, or
JsonString(...) for a raw JSON fragment), plain returns a PlainString:
import logging4s.core.{Loggable, JsonString, PlainString}
given Loggable[Money] =
Loggable.make(m => JsonString(m.cents.toString), m => PlainString(s"$$${m.cents / 100.0}"))Adapting an existing instance
Derive a Loggable for a wrapper type from the underlying type's instance with contramap (optionally renaming the
key), or redacted to mask a type everywhere it is logged:
final case class UserId(value: Int)
given Loggable[UserId] = Loggable[Int].contramap(_.value, "user_id")
final case class Password(value: String)
given Loggable[Password] = Loggable[String].contramap(_.value, "password").redacted() // always renders "***"redacted masks the whole type wherever it appears; to mask a single field of a product, use .mask in the
per-field builder.
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. A nested path
(_.address.city), a method call or a member that is not a constructor field is a compile error, so a policy can
never silently apply to nothing:
import logging4s.core.Loggable
import logging4s.core.deriving.MaskMode
final case class Account(id: Int, email: String, password: String, address: Address)
object Account:
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
.derivedunembed flattens the JSON only; the plain rendering keeps the field nested.
MaskMode.Full replaces every character, so it still reveals the length; for secrets prefer
MaskMode.Fixed("***"), which renders the same mask whatever the value. KeepLast(n) / KeepFirst(n) show part of
the value, for card numbers and the like.