Design notes
-
Scala 3 only. The API is
given-based; derivation usesMirrorand inline, with no runtime reflection. -
derivesvsfromEncoders.derivesassembles JSON itself (string building; useful when no codec exists);fromEncodersdelegates to your codec (faster, and the log JSON matches your wire JSON). Both coexist per type. -
Lazy values, eager keys. A
LoggableValueholds the value and itsLoggableand renders on first read, so a suppressed record pays for neither representation. Keys are known up front, which is what lets merging, deduplication and key-name normalization run without forcing anything. -
Custom backends. Implement
withContext/emit/enabled/unit— the twenty-four per-level methods arefinaland forward toemit.emitWith(context, …)logs one record with an extra context; its default goes throughwithContext(context).emit(…), and a backend that overrides it can merge the context into the record instead of building a logger per record, as every shipped backend does forLogging.contextual.LoggingFactory,Delay,Position,LogRecord.prepare/LogRecord.contextandLogMessage.renderare the public SPI for a backend that isn't shipped here;LogRecordis where an event is turned into its final field set, so a new backend gets context merging, key normalization, deduplication, reserved envelope names and thesourcefield by calling it rather than reimplementing them.StructuredJson.Builderwrites a JSON line in one presized buffer for a backend that renders the line itself. -
Writes happen on the calling thread, synchronously, and there is no queue inside logging4s. A log call is
Delay[F].delay { render; write }, which forcats-effectisSync[F].delayand for ZIO isZIO.attempt— both of which declare the work as cheap and CPU-bound. That is right for a warm sink and wrong for one that can block: stdout in a container nobody is reading fills its pipe buffer and the write parks a compute worker.Two deliberate consequences. First, asynchrony is your logging framework's job, not ours — logback's
AsyncAppenderand log4j2'sAsyncAppender/AsyncLogger(lock-free, on the LMAX Disruptor) already do this well, and a second queue incorewould only add double buffering and a second place to lose records on abrupt shutdown. Second, if your sink really can block,Delayis the extension point — supply your own instance and the library uses it everywhere:scalaimport logging4s.cats.CatsInstances.given given Delay[IO] = new: // wins over the imported instance override def delay[A](a: => A): IO[A] = Sync[IO].blocking(a) override val unit: IO[Unit] = IO.unitThis keeps the rest of the application running while a log write waits, at the cost of a thread hop per record — which for a fast sink can exceed the cost of the write itself, so it is a trade to make deliberately and not a default we impose.
-
A new organization is a major version. Moving from
org.logging4stonow.sendlinechanges no class, and MiMa agrees, so by the letter of semver it could have been6.1.1. It is7.0.0because a build tool sees a new organization as a new library: it will not evictorg.logging4s6.1.0in favour ofnow.sendline6.1.1, and with both in a graph the same classes come from two jars, in classpath order, with no warning. That is a change the user has to make by hand, inbuild.sbt, and a version bump that looks routine would hide it. The package stayslogging4s— renaming it would turn a one-line build change into an edit of every import. The old coordinates are not left to rot:org.logging4s7.0.0is a relocation, which Maven and Coursier follow to thenow.sendlinemodule of the same name and version — checked against sbt2.0.12by resolvingorg.logging4s:logging4s-logback_3:7.0.0from a scratch repository and getting thenow.sendlinejars. A relocation cannot reach back, though: a build pinned to6.1.0underorg.logging4sstays there until someone edits it. -
Known limitations.
- The
sourcefield is a file name and line, so two files of the same name in different packages read the same. - In the plain message text, a collection of products joins its elements with a comma, which most
plainValuesStyles also put between fields —[id -> (1), name -> (John),id -> (2), name -> (Jane)].CurlyMapbrackets each product; otherwise read the JSON for the exact structure. - With ZIO, the write is suspended by
ZIO.attemptorZIO.succeedinside logging4s'sDelayinstance, so theTraceZIO records for it — what a fiber dump or an execution trace shows — points at that instance rather than at your call site. Thesourcefield is unaffected: it is captured where you call the logger.
- The