SENDLINE open source

Logging

Logging.create returns F[Logging[F]]; the log methods return F[Unit]. There are createTry / createEither / createUnsafe variants for non-effect code.

scala
for
  log <- Logging.create[IO]("OrderService")
  _   <- log.info("service started")
  _   <- log.info("order placed", user, order)          // any number of values
  _   <- log.error("payment failed", throwable, order)  // with a cause
  scoped = log.withContextValues(requestId.asLogValue("request_id"))
  _   <- scoped.info("handled")                         // context attached to every line
yield ()

The passed message is joined with the plain rendering of the values, and each value is attached as structured data for the backend to emit as fields. Any value whose type has a Loggable can be passed as it is — user, List(1, 2), Some(order) — and converts under the key of its Loggable, with no import scala.language.implicitConversions (LoggableValue is declared an into target); asLogValue("key") picks the key yourself.

Every record also carries the call site as a source field: the file name and line, OrderService.scala:42, without the directory or package — two files of the same name in different packages read the same.

The call site is captured at compile time — Position is an implicit parameter filled in by an inline given, so it costs a constant in the emitted code rather than the stack-trace walk that %F:%L and log4j2's location info do at log time. One consequence worth knowing: it is the position of the log call, so if you wrap logging in a helper, every record points at the helper. Take Position as a parameter and it follows your caller instead:

scala
def audit(action: String)(using Logging[IO], Position): IO[Unit] =   // without `using Position`,
  Logging[IO].info(action)                                          // source points here, not at the caller

Duplicate keys resolve by specificity: a call-site value overrides a context value with the same key, and a later withContext overrides an earlier one. Only duplicates within a single call are suffixed (k, k_2), since those are two values you deliberately passed.

An interpolator is available for terser call sites — import the log syntax and have a given Logging[F] in scope:

scala
import logging4s.core.syntax.logging.*

info"order placed: $order"   // == log.info("order placed", order.asLogValue("order"))

The key comes from what you interpolate:

Hole Key
an identifier, $order order
a selection rooted in one, ${user.id}, ${items.size} the path joined, user_id, items_size
a service selector, ${maybe.get}, ${xs.head}, ${pair._1} the value's Loggable key, as for any other expression
any other expression, ${compute()}, ${Point(1, 2).x} the value's Loggable key

Values render lazily, and every log call checks the level before it builds anything — a debug under a logger set to INFO evaluates neither its values (nor an interpolator's expressions) nor their JSON, and allocates no effect. The check happens when the F[Unit] is constructed, so a log action built once under a disabled level and run later stays suppressed; build and run it in the same place (the usual for comprehension) and this never comes up.

A hole whose static type is a Throwable becomes the record's cause rather than a value, so the backend gets the exception and keeps its stack trace — error"payment failed $e" is log.error("payment failed", e). With several, the last one is the cause and the earlier ones stay values.

When the level is only known at runtime, log(level, …) takes it as the first argument, with the same four shapes as the per-level methods and the same static-message check:

scala
log.log(if attempt > 3 then Level.Warn else Level.Debug, "retrying", attempt.asLogValue("attempt"))

Value-building helpers (asLogValue, mapPlain, withKey) are in logging4s.core.syntax.loggable.*; logging4s.core.syntax.all.* brings both. asLogValue(key) renders through the type's Loggable; withKey(key) is for a type that has codecs but no Loggable — it builds one from the JsonEncoder and PlainEncoder on each call, so for a type you log often, given Loggable[A] = Loggable.fromEncoders and asLogValue is the better pair.

A Loggable that throws never costs you the record. The value renders as [failed to render: java.lang.IllegalStateException: …] in both forms, the rest of the record is written, and the logging effect succeeds — the contract SLF4J and log4j2 keep for a parameter whose toString throws.

Control characters inside values — newlines, carriage returns, terminal escape sequences, the Unicode line separators — are escaped when values are rendered into the message text (\n, \r, \u001b), and so is the cause's message, which often carries user input. A value can therefore never forge a second line in a plain-text log. The structured JSON channel was already safe; your own static message text is left as written.

Fiber-local context

withContext attaches values to one logger. To attach them to everything logged while an effect runs — a request id set by a middleware, say — without threading a logger through every call, wrap the logger with Logging.contextual and scope the effect. The context lives in the runtime's own fiber-local storage, so it follows the effect across threads and into the fibers it forks:

scala
import logging4s.zio.ZioInstances.given

val log = Logging.contextual(base)                              // reads the ambient context on every record

ZioLoggingContext.scoped(requestId.asLogValue("request_id")) {  // any ZIO[R, E, A]
  handle(request)                                                // every record inside carries request_id
}
Runtime Storage LocalContext for Scope an effect with
cats-effect IOLocal IO: IOLocalContext.make; any F with LiftIO and MonadCancelThrow, such as Kleisli[IO, R, *]: IOLocalContext.lifted[F], or lift[F](local) over an IOLocal you share local.scoped(values*)(fa)
ZIO one global FiberRef any ZIO[R, E, *], as a given ZioLoggingContext.scoped(values*)(zio)
Kyo one global Local any A < S, as a given KyoLoggingContext.scoped(values*)(v)

A call-site value still wins over the ambient context, and the ambient context wins over the logger's own withContext, since it is scoped closer to the call. Nested scopes add to each other — an inner scope that sets a key again overrides it until it ends — and the previous context comes back when a scope ends, however it ends: failure, cancellation and interruption included. A disabled level does not read the fiber-local at all. For any other effect, LocalContext[F] is two methods — use and scoped — to implement over whatever fiber-local your runtime has.

Testing

Logging.noop[F] discards everything. logging4s.core.testing.RecordingLogging[F] captures each record the way a backend would emit it — context merged, keys normalized and deduplicated — so a test can assert on it without a logging framework:

scala
val log = RecordingLogging[Try]()
service(log)

log.recorded.map(_.message) shouldEqual Seq("user created")
log.recorded.head.plainOf("user_name") shouldEqual Some("John")
log.recorded.head.jsonOf("user_name") shouldEqual Some("\"John\"")   // valueOf is this JSON form too
built from README.md at v7.0.0 edit this page on GitHub