Migration
From 6.1 to 7.0
Only the organization changes. The code is 6.1.0's, the package is still logging4s, and MiMa checks every module of
7.0.0 against org.logging4s 6.0.0 and 6.1.0 and finds nothing.
- "org.logging4s" %% "logging4s-cats" % "6.1.0"
+ "now.sendline" %% "logging4s-cats" % "7.0.0"A build tool treats a new organization as a new library, so it will not evict one in favour of the other. If anything
in your graph still brings org.logging4s 6.x — an internal library not yet moved — both jars land on the classpath
with the same classes, and which one loads depends on classpath order. Move that library too, or exclude the old
organization where it comes in:
libraryDependencies += ("com.example" %% "internal-commons" % "1.2.3").excludeAll(ExclusionRule("org.logging4s"))org.logging4s 7.0.0 exists as a relocation: each module is a pom whose <relocation> names now.sendline, with
no jar of its own. Maven, and sbt through Coursier, resolve it to the now.sendline module of the same name and
version, so a build — or Scala Steward — that only bumps the version lands on the new organization. The pom's
description and the relocation's message both say where it went, so Maven prints the move as it follows it and Central
shows it on the old coordinates' page; sbt follows it without saying so. Nothing is published under org.logging4s
after it.
From 6.0 to 6.1
6.1 is binary compatible with 6.0 — MiMa checks every module against it — and call sites compile unchanged, with one deliberate exception (8). What moves is output, where 6.0 rendered a value in the wrong shape, under a key that did not say what it was, or not at all.
- "org.logging4s" %% "logging4s-cats" % "6.0.0"
+ "org.logging4s" %% "logging4s-cats" % "6.1.0"1. Some(x) renders as x. Through asLogValue or the interpolator, a Some came out as a one-element array
under the plural key ("ints":[1]); it now renders like the Option it is ("int":1), as a directly passed one
already did. List and Vector passed directly now compile, where 6.0 found two ambiguous conversions.
2. An interpolated selection keys by its path. info"… ${user.id}" logged under id; it is now user_id, and
${items.size} is items_size. A service selector (.get, .head, ._1) and any other expression keep the
value's Loggable key — see the interpolator table.
3. A case class without fields renders as {}, not as its name; a case object keeps its name. Under
sumTypeDiscriminator, a variant without fields renders as {"type":"Pending"} rather than "Pending", so the field
keeps one JSON type.
4. A Throwable value nests its cause chain — {"class":…,"message":…,"cause":{…}}, five deep and cycle-safe.
5. The console describes the cause in the message — payment failed: class=…, message=boom — as every other
backend already did.
6. Direct calls check the level before evaluating their arguments. log.debug("x", expensive()) under INFO no
longer calls expensive(), as the interpolator already behaved — a side effect inside a log argument runs only when
the record is written.
7. A Loggable that throws renders a placeholder. 6.0 failed the logging effect, or with logback could lose the
line; the value now renders as [failed to render: …] and the rest of the record is written.
8. The builder rejects a selector that is not a constructor field. Loggable.deriving[A].hide(_.someDef) compiled
and hid nothing; it is now a compile error naming the member. It only fires on a policy that never applied.
9. Smaller output fixes. Keys of the context given at creation that normalize to one name (userId, user_id)
collapse, as withContext already did; slf4j suffixes a value named after an envelope field (level_2) like the
other backends; Unit counts as absent, so omitAbsentValues drops it; and Logging4sEncoder writes an MDC entry or
key-value pair without a value as null — 6.0 lost the whole line on a null MDC value and wrote a null
key-value pair as the string "null".
New in 6.1 (optional — pick these up while you're here)
- No
implicitConversionsimport at call sites —LoggableValueis anintotarget. - Fiber-local context for more effects —
IOLocalContext.lifted[F]/lift[F](local)for anyFwithLiftIOandMonadCancelThrow, andLocalContextfor everyZIO[R, E, *]and kyoA < S; see Fiber-local context. - More standard instances —
Year,YearMonth,MonthDay,Month,DayOfWeek,Period,ZoneId,ZoneOffset,java.util.Date,Duration,URI,URL,InetAddress,Path,Locale,CurrencyandTry; see the default keys. fromEncodersalways compiles with a codec, falling back to the codec's JSON forplainwhen the type cannot be derived.Logging4sEncoderis configurable — field names andincludeThread/includeMdc/includeKeyValuePairsfromlogback.xml.RecordedEvent.jsonOf/plainOfread a recorded value in either form;valueOfstays the JSON form, so a string comes back quoted (Some("\"svc\"")).Logging.emitWithfor a backend that merges an ambient context into the record itself.- 38–49% fewer bytes per record on every backend; see Benchmarks.
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"1. An interpolated Throwable is the cause. error"payment failed $e" used to attach e as a field
{"class":…,"message":…} and pass no cause, so the stack trace was lost. The exception now reaches the backend as the
cause: the record gains stack_trace, the message text gains class=…, message=…, and the e field is gone. To keep
an exception as a plain value, pass e.asLogValue("e").
2. Control characters in values are escaped in the message text. A value containing a newline used to produce a
second, fake line in plain-text logs (PatternLayout, console plain). It now renders as \n; carriage returns,
terminal escapes and the Unicode line separators are escaped too, and so is the cause's message. JSON fields are
unchanged.
3. RecordingLogging behaves like a backend. RecordedEvent gains fields — the merged, key-normalized,
deduplicated values a backend would emit — and keys / valueOf read from it. That fixes two 5.x bugs: valueOf
returned the context value where a call-site value overrides it, and keys were not normalized (userId where every
backend writes user_id). values and context are still there, now normalized, and RecordingLogging.apply takes
the LoggableEncodingConfig (the default given applies).
4. Map keys that collide are suffixed. Two keys with the same JSON form, such as None and Some("null"), used
to produce {"null":1,"null":2}. The second is now null_2.
5. Logging4sEncoder writes each key once. An MDC or key-value entry that shares a key with a logging4s value is
dropped, and one named after an envelope field is suffixed; 5.x wrote both. And with the stock LogstashEncoder, a
line no longer carries "tags":["logging4s.values"] — the name of the internal marker leaked into the tags field.
6. Console color = "auto" stays plain when the output is not a terminal on JDK 22+, where System.console() is
no longer null for redirected output.
7. New members. LoggableEncodingConfig gains omitAbsentValues and sumTypeDiscriminator (appended last, so
positional construction of the existing fields still compiles), Loggable gains isAbsent (defaulting to false, so
hand-written instances compile unchanged), and MaskMode gains Fixed — an exhaustive match on MaskMode needs
the new case.
8. logging4s-cats depends on cats-effect, not only cats-effect-kernel, because the fiber-local context is
built on IOLocal.
New in 6.0 (optional — pick these up while you're here)
- Fiber-local context —
Logging.contextualplusIOLocal/FiberRef/ kyoLocalscoping; see Fiber-local context and the runnableContextualLoggingExample, where two parallel requests keep their ownrequest_id. log(level, …)for a level chosen at runtime.omitAbsentValues = truedropsNoneinstead of renderingnull.sumTypeDiscriminator = Some("type")tags derived sum types with the variant name.MaskMode.Fixed(mask)masks without revealing the length.- More instances: cats
NonEmptyChain,NonEmptySeq,Validated; zioNonEmptyChunk; andDelay[UIO], so a ZIO service can log inUIOwithout.orDie.
From 4.x to 5.0
5.0 is a correctness release. Everyday call sites are unchanged; what moves is the shape of 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"1. Field policies compose instead of overwriting each other. In 4.x each call on the deriving builder replaced
the previous policy for that field, so .mask(_.password)(…).rename(_.password, "pan") silently unmasked the
value. Policies are now independent properties, and hide beats mask beats unembed:
Loggable.deriving[Secret].mask(_.password)(MaskMode.Full).rename(_.password, "pan").derived
// 4.x: {"pan":"hunter2"} 5.0: {"pan":"*******"}If you built FieldPolicy values yourself, it is now a case class of independent settings rather than an enum of
mutually exclusive cases.
2. Loggable.key is a def. It was a val. Instances written as override val key = … keep working unchanged;
this only matters if you wrote Loggable implementations that relied on the abstract member being a val.
3. Recursive types now derive instead of hanging. final case class Node(value: Int, next: Option[Node]) derives Loggable used to compile with an Infinite loop in function body warning and then deadlock on first use. Field
codecs are bound lazily, so recursive products, mutually recursive products and recursive enums all work.
4. Duplicate field names are resolved everywhere. Values that would have collided now get a free _2 suffix:
a user value named level or message no longer fights the envelope, two case-class fields that normalize to the
same key stay distinct, a tuple rendered as an object keys its elements apart, and unembed falls back to nesting
rather than splicing a name the parent already uses.
5. Context keys are compared after normalization. withContext(userId = 1) followed by
withContext(user_id = 2) used to keep both under the default SnakeCase; the later one now replaces the earlier,
matching what call-site values already did.
6. Map keys come from the key's JSON form, not its plain form. For the built-in instances the rendered name is
unchanged, but keys whose plain rendering collided (or was masked) no longer collapse into one field.
7. Non-finite Float/Double render as JSON null. NaN and ±Infinity used to be written as bare literals,
which made the whole record unparseable. The plain rendering still shows NaN / Infinity.
8. PlainValuesStyle.Logfmt quotes and escapes values. A value containing a space, =, a quote or a newline is
now a quoted logfmt value instead of silently reading as several fields.
9. ZIO Debug-derived PlainEncoders render correctly. The adapter used to strip the first and last character
of every rendering: 123 became 2, true became ru, and single-character renderings threw.
10. Key-name styles use Locale.ROOT. Field names no longer depend on the JVM's default locale.
11. A log message must stay static text. An interpolated message is now a compile error, because the message is the aggregation key — baking values into it produces one distinct key per request:
- log.info(s"user $id created")
+ log.info("user created", id.asLogValue("user_id"))
+ info"user created $id" // the interpolator extracts them for youA message that merely varies is still fine (log.info(if created then "user created" else "user updated")), and so
is an interpolation without holes. Only holes are rejected.
12. ConsoleConfig holds a Threshold, not a Level. This only affects code that builds the console config
programmatically — HOCON configuration is unchanged apart from gaining off:
- ConsoleConfig(Level.Info, Format.Json, ColorMode.Off, Stream.Stdout, -1)
+ ConsoleConfig(Threshold.At(Level.Info), Format.Json, ColorMode.Off, Stream.Stdout, -1)
- config.levelFor("io.netty")
+ config.thresholdFor("io.netty")New in 5.0 (optional — pick these up while you're here)
-
Named tuples render as objects keyed by their field names, with no arity limit — see Named tuples. There is a runnable
NamedTupleExample. -
offas a console threshold, at the root or per logger — see the console section. -
includeValuesInMessage = falsekeeps the message as bare static text and renders values only into the structured channel, halving the per-event rendering work for structured pipelines. The console's plain format ignores the setting, since it has nowhere else to put the values. -
A test toolkit —
Logging.noop[F]andlogging4s.core.testing.RecordingLogging[F], which captures every record for assertions without a logging framework:scalaval log = RecordingLogging[Try]() service(log) log.recorded.map(_.message) shouldEqual Seq("user created") log.recorded.head.valueOf("user_id") shouldEqual Some("1")
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.
1. Your application must be on Scala 3.9. Every module moved from 3.3 LTS to the new 3.9 LTS, and TASTy is
not forward compatible — a 3.3 application cannot read these artifacts at all. Stay on 3.0.x until you move.
- "org.logging4s" %% "logging4s-cats" % "3.0.0"
+ "org.logging4s" %% "logging4s-cats" % "4.0.0"2. Map renders as a JSON object. Map("a" -> 1) used to render as [["a",1]], an array of pairs that is
awkward to query; it now renders as {"a":1}, keyed by the plain form of each key. The plain form follows
plainValuesStyle (a -> (1)) rather than the tuple style. If a dashboard depends on the old shape, set
LoggableEncodingConfig(mapAsObject = false).
3. Your own given definitions need Scala 3's new syntax. This is a Scala change rather than a logging4s one, but
it is what you hit first on 3.9:
- given MyLoggable[A](using L: Loggable[A]): Loggable[Wrapper[A]] = ...
+ given MyLoggable: [A] => (L: Loggable[A]) => Loggable[Wrapper[A]] = ...Fixed in 4.0
- The interpolator rendered eagerly.
info"..."built both the JSON and the plain form of every value up front. 3.0 madeLoggableValuelazy, but the interpolator — the main call path — did not use it. A backend that reads only one of the two no longer pays for the other. - Field names were not JSON-escaped. A key containing a quote (from
rename, or aMapkey) produced invalid JSON. Derived instances escape their field names once, when the instance is built, so this costs nothing at log time. - A hole in the middle of a message left a double space.
info"created $user with $n"producedcreated with; the gap is now collapsed.
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.
1. Bump the version. Coordinates are otherwise identical:
- "org.logging4s" %% "logging4s-cats" % "2.0.1"
+ "org.logging4s" %% "logging4s-cats" % "3.0.0"2. Logging[F] now has four abstract members instead of twenty-one. If you implement Logging yourself (a
custom backend, or a test double), implement withContext / emit / enabled / unit; the twenty per-level
overloads became final and forward to emit:
def withContext(context: LoggingContext): Logging[F]
def emit(level: Level, message: String, cause: Option[Throwable], values: Seq[LoggableValue])(using Position): F[Unit]
def enabled(level: Level): Boolean
def unit: F[Unit]3. Level moved to logging4s.core. It used to live in logging4s.console:
- import logging4s.console.Level
+ import logging4s.core.Level4. LoggableValue is a sealed trait, and values render lazily. LoggableValue(key, plain, json) still builds a
pre-rendered value, but asLogValue and friends now defer rendering until the backend reads it. It is no longer a
case class: copy and pattern matching on it are gone — use withKey to rewrite a key.
5. None and Unit render as JSON null. They used to render as an empty string, which produced invalid JSON
({"nick":}). If anything downstream parsed that, it can now parse it.
6. Log records carry a source field. Every record gets source = "File.scala:42". Turn it off with
LoggableEncodingConfig(includeSourcePosition = false).
7. Context values merge by key instead of being suffixed. A later withContext overrides an earlier one with the
same key, and a call-site value overrides the context. Duplicates within one call are still suffixed (k, k_2).
8. log4j2 LoggableMapMessage takes Seq[(String, String)] instead of Map, so field order follows the call
site.
New in 3.0 (optional — pick these up while you're here)
- Call-site capture — every record carries
source = "OrderService.scala:42". - Level gating in the interpolator —
debug"..."builds nothing at all when the level is off (see Benchmarks). LoggableforThrowable(and any subtype),LocalDate/LocalTime/OffsetDateTime/java.time.Duration, andArray[T]— soerror"request failed $ex"now compiles.Logging.mapK— lift aLogging[F]into another effect with[A] => F[A] => G[A].- Per-logger levels for the console backend —
logging4s.console.levels { "io.netty" = "warn" }. Loggable.mapPlain— a fourth combinator alongsiderename/contramap/redacted.
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.*(or the granular syntax.loggable.* / syntax.logging.*).
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] (or [A]("k")) |
You have a JSON codec (circe / jsoniter / …) — json is the codec's own output. |
| n/a | derives Loggable / Loggable.derived[A] (or [A]("k")) |
No codec — the JSON is assembled structurally by the macro. |
| n/a | Loggable.deriving[A]….derived |
Derived, but with per-field policies (hide / mask / rename / unembed). |
make[A]("k")(encode, show) |
Loggable.make[A](json, plain) (or [A]("k")(json, plain)) |
Fully manual — you write both renderings by hand. |
Two concrete edits:
// 1.x — codec-backed make
given Loggable[User] = Loggable.make("user") // required a JsonEncoder + PlainEncoder
// 2.0 — that is exactly fromEncoders
given Loggable[User] = Loggable.fromEncoders // key "user" from the type name; or fromEncoders("user")
// 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 — consistently across all four.
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 (ValueKey(...), JsonString.quoted(...), PlainString(...)). Also rename / contramap / redacted are now
final; if you overrode them, compose instead (3.0 adds mapPlain to the same set).
New in 2.0
derives Loggable— structural derivation, no JSON library required.- Field policies —
Loggable.deriving[A].hide(_.password).mask(_.email)(MaskMode.KeepLast(4)).rename(_.id, "account_id").unembed(_.address).derived. - Log interpolator —
info"user created: $user". - App-wide
LoggableEncodingConfig— key-name style, tuple / collection rendering. - New backends — standalone
logging4s-console; opt-inLogging4sEncoderfor logback (nested JSON, no Jackson); nested JSON for log4j2 viaJsonTemplateLayout.