Logging
Logging.create returns F[Logging[F]]; the log methods return F[Unit]. There are createTry / createEither /
createUnsafe variants for non-effect code.
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:
def audit(action: String)(using Logging[IO], Position): IO[Unit] = // without `using Position`,
Logging[IO].info(action) // source points here, not at the callerDuplicate 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:
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:
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:
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:
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