SENDLINE open source
open source / logging4s / design notes

Design notes

  • Scala 3 only. The API is given-based; derivation uses Mirror and inline, with no runtime reflection.

  • derives vs fromEncoders. derives assembles JSON itself (string building; useful when no codec exists); fromEncoders delegates to your codec (faster, and the log JSON matches your wire JSON). Both coexist per type.

  • Lazy values, eager keys. A LoggableValue holds the value and its Loggable and 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 are final and forward to emit. emitWith(context, …) logs one record with an extra context; its default goes through withContext(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 for Logging.contextual. LoggingFactory, Delay, Position, LogRecord.prepare / LogRecord.context and LogMessage.render are the public SPI for a backend that isn't shipped here; LogRecord is where an event is turned into its final field set, so a new backend gets context merging, key normalization, deduplication, reserved envelope names and the source field by calling it rather than reimplementing them. StructuredJson.Builder writes 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 for cats-effect is Sync[F].delay and for ZIO is ZIO.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 AsyncAppender and log4j2's AsyncAppender / AsyncLogger (lock-free, on the LMAX Disruptor) already do this well, and a second queue in core would only add double buffering and a second place to lose records on abrupt shutdown. Second, if your sink really can block, Delay is the extension point — supply your own instance and the library uses it everywhere:

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

    This 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.logging4s to now.sendline changes no class, and MiMa agrees, so by the letter of semver it could have been 6.1.1. It is 7.0.0 because a build tool sees a new organization as a new library: it will not evict org.logging4s 6.1.0 in favour of now.sendline 6.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, in build.sbt, and a version bump that looks routine would hide it. The package stays logging4s — renaming it would turn a one-line build change into an edit of every import. The old coordinates are not left to rot: org.logging4s 7.0.0 is a relocation, which Maven and Coursier follow to the now.sendline module of the same name and version — checked against sbt 2.0.12 by resolving org.logging4s:logging4s-logback_3:7.0.0 from a scratch repository and getting the now.sendline jars. A relocation cannot reach back, though: a build pinned to 6.1.0 under org.logging4s stays there until someone edits it.

  • Known limitations.

    • The source field 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)]. CurlyMap brackets each product; otherwise read the JSON for the exact structure.
    • With ZIO, the write is suspended by ZIO.attempt or ZIO.succeed inside logging4s's Delay instance, so the Trace ZIO records for it — what a fiber dump or an execution trace shows — points at that instance rather than at your call site. The source field is unaffected: it is captured where you call the logger.
built from README.md at v7.0.0 edit this page on GitHub