SENDLINE open source
open source / logging4s / migration

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.

diff
- "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:

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

diff
- "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 implicitConversions import at call sites — LoggableValue is an into target.
  • Fiber-local context for more effects — IOLocalContext.lifted[F] / lift[F](local) for any F with LiftIO and MonadCancelThrow, and LocalContext for every ZIO[R, E, *] and kyo A < 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, Currency and Try; see the default keys.
  • fromEncoders always compiles with a codec, falling back to the codec's JSON for plain when the type cannot be derived.
  • Logging4sEncoder is configurable — field names and includeThread / includeMdc / includeKeyValuePairs from logback.xml.
  • RecordedEvent.jsonOf / plainOf read a recorded value in either form; valueOf stays the JSON form, so a string comes back quoted (Some("\"svc\"")).
  • Logging.emitWith for 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.

diff
- "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.contextual plus IOLocal / FiberRef / kyo Local scoping; see Fiber-local context and the runnable ContextualLoggingExample, where two parallel requests keep their own request_id.
  • log(level, …) for a level chosen at runtime.
  • omitAbsentValues = true drops None instead of rendering null.
  • 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; zio NonEmptyChunk; and Delay[UIO], so a ZIO service can log in UIO without .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.

diff
- "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:

scala
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:

diff
- log.info(s"user $id created")
+ log.info("user created", id.asLogValue("user_id"))
+ info"user created $id"                              // the interpolator extracts them for you

A 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:

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

  • off as a console threshold, at the root or per logger — see the console section.

  • includeValuesInMessage = false keeps 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] and logging4s.core.testing.RecordingLogging[F], which captures every record for assertions without a logging framework:

    scala
    val 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.

diff
- "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:

diff
- 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 made LoggableValue lazy, 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 a Map key) 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" produced created 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:

diff
- "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:

scala
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:

diff
- import logging4s.console.Level
+ import logging4s.core.Level

4. 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).
  • Loggable for Throwable (and any subtype), LocalDate / LocalTime / OffsetDateTime / java.time.Duration, and Array[T] — so error"request failed $ex" now compiles.
  • Logging.mapK — lift a Logging[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 alongside rename / 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:

diff
- "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:

diff
- 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:

scala
// 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-in Logging4sEncoder for logback (nested JSON, no Jackson); nested JSON for log4j2 via JsonTemplateLayout.
built from README.md at v7.0.0 edit this page on GitHub