zio-scribe attempts to provide an idiomatic, high‑performance integration between ZIO and Scribe, enabling the use of
Scribe as the underlying logging backend for ZIO’s standard logging façade (ZIO.log, ZIO.logInfo, …). It implements
a native ZLogger that faithfully propagates ZIO context (fibre ID, spans, annotations, and causes) into Scribe’s
structured data model, and adds fibre‑safe, effect‑scoped configuration via FiberRef.
- Basic Setup
- Programmatic Bootstrap (Simple → Complex)
- Global Configuration (Formatters / Handlers / Writers)
- Scoped (Fibre‑Local) Features
- ZIO Context Integration
- Configuration (Mapping ZIO Context)
- Dynamic Runtime Reconfiguration
Whilst Scribe can be used directly inside ZIO effects, doing so bypasses ZIO’s built‑in logging infrastructure. In practice this:
- Loses ZIO‑specific context (fibre IDs, spans, annotations, and structured
Causedata), - Encourages thread‑local context (e.g. MDC) that does not align with ZIO’s fibre scheduler,
- Fragments configuration in concurrent, nested scopes.
zio-scribe addresses these by ZIO ZLogger integration. It translates ZIO log events into Scribe LogRecords,
preserves Scribe’s hierarchical logger naming based on source site, and introduces a fibre‑safe mechanism to apply
Scribe LogFeatures within the dynamic scope of an effect.
- ZIO conformance: act as a native
ZLoggerbackend. - Context propagation: encode fibre ID, spans, annotations, and the
Causeinto Scribe’sLogRecord.dataand messages. - Fibre‑local configuration: provide effect‑scoped, leak‑free tuning using Scribe
LogFeatures via aFiberRef. - Performance: add minimal additional runtime overhead.
sbt:
libraryDependencies += "io.github.arashi01" %% "zio-scribe" % "<version>" // JVM
libraryDependencies += "io.github.arashi01" %%% "zio-scribe" % "<version>" // JVM/JS/Native (cross-platform)zio-scribe depends on:
- ZIO 2.1.x (runtime façade)
- Scribe 3.x (backend)
Install Scribe as the ZIO backend in bootstrap using the provided layer. Two principal modes exist:
- Replace (default): remove ZIO's default loggers and install Scribe (
ScribeLogging.live). - Additive: keep defaults and add Scribe (
ScribeLogging.additive).
import zio.*
import zio.scribe.ScribeLogging
object MyApp extends ZIOAppDefault:
// Replace default loggers with Scribe (drop ZIO defaults)
override val bootstrap = ScribeLogging.live
def run: ZIO[Any, Nothing, Unit] =
ZIO.logInfo("Hello, Scribe via ZIO") *>
ZIO.withSpan("db.fetch") {
ZIO.logAnnotate("requestId", "req-123") {
ZIO.logDebug("Fetching records")
}
}Notes:
- Replace variant removes ZIO defaults; additive keeps them. Both are fully scoped (loggers revert after layer scope).
- Logger name derives from call‑site class (via
Trace), preserving Scribe's hierarchical model.
zio-scribe uses a purely programmatic bootstrap model: you build a ScribeBootstrapConfig in code and apply it with
ScribeLogging.withConfig (or withConfigAdditive). No key / HOCON bootstrap exists — this keeps the library
agnostic of your external configuration schema. (ZIO Config is still used for mapping ZIO context keys; see below.)
import zio.*
import zio.scribe.*
import scribe.*
val bootstrapLayer = ScribeLogging.withConfig(
ScribeBootstrapConfig(
ScribeBootstrapConfig.Root(
level = Level.Info,
formatter = ScribeBootstrapConfig.FormatterName.Default,
handlers = Nil // empty => a single default handler is synthesised (Default formatter, min root level)
)
)
)
object MinimalApp extends ZIOAppDefault:
override val bootstrap = bootstrapLayer
def run = ZIO.logInfo("hello")val layer = ScribeLogging.withConfig(
ScribeBootstrapConfig(
ScribeBootstrapConfig.Root(
level = Level.Debug,
formatter = ScribeBootstrapConfig.FormatterName.Colored,
handlers = List(
// Explicit handler spec (overrides root level + its own formatter)
ScribeBootstrapConfig.HandlerSpec(
minimumLevel = Some(Level.Warn),
formatter = Some(ScribeBootstrapConfig.FormatterName.Simple)
)
)
)
)
)val cfg = ScribeBootstrapConfig(
root = ScribeBootstrapConfig.Root(Level.Info, ScribeBootstrapConfig.FormatterName.Simple, Nil),
loggers = Map(
"com.acme.service" -> ScribeBootstrapConfig.Named(Level.Debug),
"com.acme.service.internal" -> ScribeBootstrapConfig.Named(Level.Error)
)
)
val layer = ScribeLogging.withConfig(cfg)val layer = ScribeLogging.withNamedLevels(
rootLevel = Level.Info,
formatter = ScribeBootstrapConfig.FormatterName.Default,
namedLevels = Map("svc" -> Level.Debug, "svc.dao" -> Level.Warn),
handlers = Nil
)val additiveLayer = ScribeLogging.withConfigAdditive(cfg) // keeps existing loggersUse the Scribe service (via ScribeConfigurator) to configure Scribe functionally:
import scribe.handler.LogHandler
import scribe.format.*
import scribe.writer.*
import zio.*
import zio.scribe.*
val pretty = Formatter.simple
val console = Writer.console
val configureRoot =
ScribeLogging.configured(_.clearHandlers().withHandler(LogHandler(pretty, console)))
// with named logger
val configureByName: ZIO[Scribe, Nothing, Unit] =
configure("com.example")(_.clearHandlers().withMinimumLevel(Level.Info))Because configuration mutates Scribe’s global registry, access is wrapped in ZIO effects for purity and composability.
Apply Scribe LogFeatures within an effect’s dynamic scope using the fibre‑local featuresRef. Use the provided syntax
extensions for convenience:
import scribe.{boost, data}
import zio.*
import zio.scribe.*
val program =
ZIO.logInfo("outside") *>
ZIO.logInfo("user action").withScribeData("userId" -> "u-42", "role" -> "admin") *>
ZIO.logInfo("elevated").withScribeMinLevel(Level.Warn) *>
ZIO.logInfo("custom boost").withScribeBoost(_ + 100.0)Semantics:
- Features are stored in
ScribeContext.featuresRef: FiberRef[List[LogFeature]]. - Fork: children inherit the parent's features unchanged.
- Join: child's features are appended to parent's features on explicit join, enabling compositional logging patterns.
- Scoping: features installed via
locally/locallyScopedare automatically cleaned up when the scope ends (via acquire/release), independent of join behavior. - Each emitted
LogRecordis transformed by the features in order before being written.
In addition to the fluent extension methods you can compose feature scoping using reusable transformations or with a declarative layer:
import zio.*
import zio.scribe.*
import scribe.data
// Reusable polymorphic transformation (type parameters chosen at call‑site)
val enrich: [R, E, A] => ZIO[R, E, A] => ZIO[R, E, A] =
ScribeTransforms.features(data("tenant", () => "acme"))
val run1 = enrich(ZIO.logInfo("hello"))
// Feature layer: installs features for any effect provided within its scope
val tenantLayer: ZLayer[Any, Nothing, ScribeFeaturesLayer.Applied] =
ScribeFeaturesLayer(data("tenant", () => "acme"))
val run2 = ZIO.logInfo("hello inside layer").provide(tenantLayer)Guidance:
ScribeTransforms.featuresandScribeAspects.featuresare identical; pick whichever name you find more expressive. Both return a polymorphic[R,E,A] => ZIO[R,E,A] => ZIO[R,E,A].ScribeFeaturesLayeruses an acquire/finalizer pattern to append features for the lifetime of the layer and then restore the previous list. It exports a marker serviceAppliedso that your code can depend on it (preventing unused-layer warnings) if desired.- Prefer the layer when wiring environments or when you want scoping expressed alongside other layers; prefer the transformation for higher‑order utilities or ad‑hoc composition.
When you explicitly fork and join fibers, logging context flows compositionally:
import zio.*
import zio.scribe.*
import scribe.data
// Context from child fiber merges into parent on join
val processTask = for {
fiber <- ZIO.logInfo("Processing task")
.withScribeData("taskId" -> "123", "priority" -> "high")
.fork
_ <- ZIO.logInfo("Parent continues work")
_ <- fiber.join // Child's context (taskId, priority) merges into parent
_ <- ZIO.logInfo("After join - parent can observe child's context")
} yield ()
// Parallel context aggregation
val parallelWork = for {
fibers <- ZIO.foreach(List("task-1", "task-2", "task-3")) { taskId =>
ZIO.logInfo(s"Processing $taskId")
.withScribeData("taskId" -> taskId)
.fork
}
_ <- ZIO.foreach(fibers)(_.join) // All task contexts accumulated
_ <- ZIO.logInfo("All tasks complete with their contexts preserved")
} yield ()Key distinction:
- Scoped features (via
withScribeData,locallyWith): automatically cleaned up when scope ends - Fork/join: child's final feature state merges with parent on explicit join
This enables distributed tracing, audit logging, and parallel context aggregation patterns while maintaining proper scoping guarantees.
What is propagated into Scribe:
- Fibre identity —
zio.fiberIdrecorded as the fibre’s thread name in the data map. - Spans —
zio.spansis a compact rendering of nestedLogSpanlabels (e.g.http -> db). - Annotations — all ZIO annotations are included twice: individually with the prefix
zio.annotation.<key>and as a single summary string underzio.annotations.summary. - Causes — if a
Throwableis present in theCause(defect or failure), it is logged as aTraceLoggableMessagepreserving the stack trace; otherwise the rawCauseis attached to the data map aszio.causefor advanced formatters.
Performance considerations:
- The underlying Scribe
Loggeris looked up by class andincludes(level)is checked before any heavy work. - All record data values are lazy (
() => Any), so they are evaluated only if handlers/formatters consume them.
ScribeConfig controls how ZIO context elements map to Scribe record data keys. Defaults are sensible, but you can
override via ZIO Config (HOCON / Typesafe Config / etc.) if desired. This is separate from bootstrap — only context key
mapping remains externally configurable.
Expected configuration keys (under zio.scribe.context by default):
fiber-id(default:"zio.fiberId")spans(default:"zio.spans")annotations-summary(default:"zio.annotations.summary")annotation-prefix(default:"zio.annotation.")cause-key(default:"zio.cause")
Example HOCON:
zio.scribe.context {
fiber-id = "fiber"
spans = "spans"
annotations-summary = "annotations"
annotation-prefix = "ctx."
cause-key = "cause"
}Load it implicitly by using any of the ScribeLogging.* layers (they include ScribeConfig.live). To use an
alternative root path, compose ScribeConfig.live("my.app.logging") >>> ScribeLogging.live (or another variant).
After bootstrap you can adjust levels without rebuilding the entire layer:
import zio.scribe.*
import scribe.*
val program = for
_ <- ScribeLogging.setRootLevel(Level.Error) // raise root threshold
_ <- ScribeLogging.reconfigureNamedLevels(Map(
"com.acme.service" -> Level.Debug, // more verbose for one module
"com.acme.service.internal" -> Level.Error
))
_ <- ZIO.logDebug("debug for service")
yield ()These APIs mutate Scribe's registry (effectfully) while preserving ZIO purity boundaries.
Combine programmatic bootstrap (handlers/formatters) with runtime level tuning and scoped features:
import zio.*
import zio.scribe.*
val configured =
ScribeLogging.configured { root =>
root
.clearHandlers()
.withHandler(LogHandler.default)
.withMinimumLevel(Level.Debug)
}
val app: ZIO[Scribe, Nothing, Unit] =
for
_ <- configure("com.acme.service")(_.withMinimumLevel(Level.Info))
_ <- ZIO.logDebug("debug detail").withScribeData("requestId" -> "req-1")
yield ()zio-logging is a feature‑rich logging library for ZIO with multiple backends (console/file, SLF4J v1/v2, Java System
Logger), bridges (SLF4J/JUL), metrics, and its own logger context based on typed LogAnnotations. It integrates with
ZIO 2’s logging façade and can replace or augment default loggers.
zio-scribe is intentionally narrower: it focuses on providing a Scribe backend for ZIO’s façade, exposing Scribe’s
formatters, handlers, writers, and features, while keeping context propagation fibre‑safe and minimal.
Notable ZIO‑specific features provided by zio-logging but not implemented in zio-scribe:
- Logger context via typed
LogAnnotationwith aggregation semantics and helpers such asLogAnnotation.Name,CorrelationId, and MDC abstraction.zio-scribeforwards ZIO’s built‑in annotations map as plain strings into Scribe’s data map (with a configurable prefix) and a summary string; there is no typed aggregation facility. - Prebuilt console/file loggers and formats in the
Loggingmodule (e.g.consoleLogger,fileLogger) and the ability to compose loggers withcontraMap. Withzio-scribe, you configure Scribe directly (handlers, writers, formatters). There is no separateLoggingalgebra. - Bridges: SLF4J v1/v2 bridge modules, JUL bridge, JPL backend modules to capture non‑ZIO logs inside the ZIO logger.
zio-scribedoes not provide such bridges; if needed, rely on Scribe’s own ecosystem or configure bridges separately. - Metrics integration (e.g. counters on log levels). Not provided by
zio-scribeout of the box; can be implemented in Scribe via customLogHandlers.
Semantic differences:
- Logger naming:
zio-loggingcommonly usesLogAnnotation.Nameto set the logical logger name via logger context.zio-scribederives the Scribe logger name from the sourceTrace(class name). To tune routing/levels, configure Scribe loggers by package/class rather than injecting names via context. - Cause rendering:
zio-loggingformatsCauseaccording to its own formats;zio-scribeprioritises a realThrowable(defect or failure) and logs it using Scribe’s throwable message, otherwise attaches the rawCauseobject in the data map under a configurable key for advanced formatters. - Context propagation model:
zio-loggingoffers MDC with fibre‑aware semantics.zio-scribedoes not adapt Scribe’s MDC (thread‑local) to fibres; instead it provides an explicit fibre‑localfeaturesRefto installLogFeatures safely within effect scopes.
When to prefer zio-scribe:
- You want Scribe’s performance, feature set (formatters, writers, handlers), and hierarchical logger model as the backend for ZIO logging.
- You prefer minimal glue with ZIO’s façade and explicit, typed control of Scribe at the edges.
When to prefer zio-logging:
- You need SLF4J/JUL bridges, prebuilt console/file loggers, JPL integrations, or typed
LogAnnotationaggregation and MDC. - You want a ZIO‑first logging stack without adopting Scribe’s configuration model.
Using Scribe directly inside ZIO effects works, but:
- You lose ZIO façade integration (
ZIO.log*), which centralises level filtering, spans, and annotations. - ZIO context is not propagated: spans and annotations are absent, and MDC is thread‑local (unsuitable for fibres without extra handling).
- Configuration becomes subtly unsafe in concurrent code if you rely on thread‑local context.
zio-scribe preserves the ZIO façade, propagates ZIO context into Scribe records, and offers an explicit, fibre‑safe
mechanism to enrich logging within effect scopes. All bootstrap is code-first to avoid constraining your external
configuration or deployment story.
This software is released and licensed under the terms of the MIT Licence.