Baeldung Pro – Scala – NPI EA (cat = Baeldung on Scala)
announcement - icon

Learn through the super-clean Baeldung Pro experience:

>> Membership and Baeldung Pro.

No ads, dark-mode and 6 months free of IntelliJ Idea Ultimate to start with.

1. Overview

Logging is an essential aspect of programming. By introducing logging into our application, it can provide valuable insights such as what actions were performed and what errors occurred. All of the logging information can help us more easily trace the application’s flow and make debugging and troubleshooting easier. In this tutorial, we’ll explore using the zio-logging library to log within our application.

2. The zio-logging Library

The zio-logging library is a lightweight, type-safe, purely functional library that integrates with the ZIO ecosystem. It provides easy integration with various backends, such as console and file logging and SL4J. It supports both JVM and JS platforms and has a first-citizen context implemented on top of FiberRef, the local fiber storage.

To start using the library, we need to add the following dependency in our build.sbt file:

libraryDependencies += "dev.zio" %% "zio-logging" % "2.1.10"

First, we need to select our logger. For this example, we’ll use the console logger. As recommended by the library’s documentation, we should set our logger in the application bootstrap so it is set up for the whole runtime.

So, let’s create a layer for our console logger:

object BaeldungLogger {
  val consoleLogger: ULayer[Unit] = Runtime.removeDefaultLoggers >>> console()
}

This layer will remove the default loggers and add our console logger. We’ll now create an application and see the results:

object BaeldungZIOLoggingApp extends ZIOAppDefault {

val app: ZIO[Any, Nothing, Unit] =for {
  - <- ZIO.logTrace("This is a trace message")
  _ <- ZIO.logDebug("This is a debug message")
  _ <- ZIO.logInfo("This is an info message")
  _ <- ZIO.logWarning("This is a warning message")
  _ <- ZIO.logError("This is an error message")
} yield ()

override def run: ZIO[ZIOAppArgs & Scope, Nothing, Unit] =
  app.provideLayer(BaeldungConsoleLogger.consoleLogger)
}

For now, we log some simple messages with different levels: trace, debug, info, warning, and error. Our output is the following:

zio-logging-console

The default behavior is to have colored messages according to the log level and ignore trace-level messages.

3. Customizing the Logger

Now that we have a basic logger infrastructure, let’s customize it to match our needs.

3.1. Log Format

First, let’s change the layout of our log lines. We can do this by defining a LogFormat value:

private val logFormat: LogFormat = timestamp(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssAZ")).highlight(_ => LogColor.BLUE)
  |-| bracketStart |-| LogFormat.loggerName(LoggerNameExtractor.trace) |-| text(":") |-| traceLine |-| bracketEnd |-|
  fiberId |-| level.highlight |-| label("message", quoted(line)).highlight

The |-| operator concatenates all our configurations to a single object. The library provides all the configuration. In our example, we used a custom format for the date and made it blue with the highlight function. Then, we added the class with the package that produced the log along with the line, inside brackets, the fiber id, and the log level with the message highlighted with each log level’s default color. We used the trace logger name extractor to extract the class’s name. Other name extractors are available as well. Next, we need to add it to our logger:

val consoleLogger: ULayer[Unit] = Runtime.removeDefaultLoggers >>> console(logFormat, LogLevel.Trace)

We also made the root log level Trace to log the trace messages. The output now changes:

ZIO logging custom console configuration

All the available format configurations are available in the LogFormat documentation.

3.2. Log Filtering

Sometimes, in our application, we want to use different logging levels for different loggers, depending on their package. The zio-logging library provides an easy way to do this with the LogFilter class. We can use the logLevelByName method to define our mappings. In our example, we’ll define a trace level for the root logger, but we’ll constrain our log messages to info level:

private val filter =
  LogFilter.logLevelByName(LogLevel.Trace, ("com.baeldung.scala.zio.logging", LogLevel.Info))

Our consoleLogger also changes as follows:

val consoleLogger: ULayer[Unit] = Runtime.removeDefaultLoggers >>> console(logFormat, filter)

We can rerun our application and see that everything runs as expected:

ZIO logging custom format with filtering

4. File Logger

Logging into a file is widespread, as we may need to check our logs, and searching in the console is inefficient. We’ll create a new logger that will log to a file. For this example, the message will be in JSON format, but a simple text logger is also available:

val fileLogger: ULayer[Unit] = Runtime.removeDefaultLoggers >>> fileJson(Path.of("baeldung-zio-logging.log"), logFormat, LogLevel.Debug)

We’re using the same log format with our console logger, and we’re setting the log level to debug, although we could also use a custom log filter.

Finally, we’ll combine both our loggers into one and provide it in our application:

val combinedLogger: ULayer[Unit] = consoleLogger ++ fileLogger
override def run: ZIO[ZIOAppArgs & Scope, Nothing, Unit] =
  app.provideLayer(BaeldungConsoleLogger.combinedLogger)

We’ll notice that a new file was created in the root of our project with the name we provided and with the following lines:

{"text_content":"2024-08-28 17:22:2662546175+0300 [ com.baeldung.scala.zio.logging.BaeldungZIOLoggingApp : 9 ] zio-fiber-629345720 DEBUG ","message":"This is a debug message"}
{"text_content":"2024-08-28 17:22:2662546194+0300 [ com.baeldung.scala.zio.logging.BaeldungZIOLoggingApp : 10 ] zio-fiber-629345720 INFO ","message":"This is an info message"}
{"text_content":"2024-08-28 17:22:2662546198+0300 [ com.baeldung.scala.zio.logging.BaeldungZIOLoggingApp : 11 ] zio-fiber-629345720 WARN ","message":"This is a warning message"}
{"text_content":"2024-08-28 17:22:2662546201+0300 [ com.baeldung.scala.zio.logging.BaeldungZIOLoggingApp : 12 ] zio-fiber-629345720 ERROR ","message":"This is an error message"}

5. SLF4J Integration

As we mentioned above, the library offers integration with various backends. One of the most commonly used is SLF4J, which we’ll integrate into our application.

5.1. Configuring SLF4J

First, let’s add the necessary dependencies to our build.sbt file:

libraryDependencies += "dev.zio" %% "zio-logging-slf4j2" % "2.2.0",
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.5.6"

We’ll use the slf4j v2 integration, recommended over v1 and logback, as our logging framework. Logback lets us easily change the logger’s configuration with an XML file. In this example, we’ll consider that we already know how to configure logback and skip the XML file, but we’ll mention that we configured the root-level logging to info.

Next, we’ll create our logger:

val slf4jLogger: ULayer[Unit] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j

And we’ll use it in our application:

override def run: ZIO[ZIOAppArgs & Scope, Nothing, Unit] =
  app.provideLayer(BaeldungLogger.slf4jLogger)

After that, the output changes:

SLF4J zio logging integration

5.2. SLF4J Bridge

Although our project uses the ZIO ecosystem, we may want to use a non-ZIO library. The SLF4J loggers of these libraries are not compatible with our application. The zio-logging library provides a bridge to let us use them. Let’s add the necessary dependency in our build.sbt:

libraryDependencies += "dev.zio" %% "zio-logging-slf4j2-bridge" % "2.2.0"

Then, we’ll initialize our bridge and create a new logger:

val slf4jBridgeLogger: ULayer[Unit] = Runtime.removeDefaultLoggers >>> Slf4jBridge.initialize

As before, we’ll pass the logger in our application:

override def run: ZIO[ZIOAppArgs & Scope, Nothing, Unit] =
  app.provideLayer(BaeldungLogger.slf4jBridgeLogger)

We should only use the zio-logging-slf4j2 dependency or the zio-logging-slf4j2-bridge, but not both. If we do so, we’ll create a circular dependency.

6. Logging Metrics

The zio-logging library provides a powerful way to log metrics. We need to provide a backend to integrate and send our metrics. For our example, we’ll use Prometheus.

First, we’ll add the necessary dependencies for using metrics and integrating with Prometheus in our build.sbt file. For this, we’ll use the zio-metrics-connectors ecosystem:

libraryDependencies += "dev.zio" %% "zio-metrics-connectors" % "2.1.0",
libraryDependencies += "dev.zio" %% "zio-metrics-connectors-prometheus" % "2.1.0"

Now, we’re ready to create a metrics logger for our application. First, we’ll create a layer that will connect with our Prometheus server:

object BaeldungMetricsLogger {
  private val prometheusConnector
    : ZLayer[Unit, IOException, PrometheusPublisher] = (ZLayer.succeed(
    MetricsConfig(10.millis)
  ) ++ publisherLayer) >+> prometheusLayer
}

In this code, we’ve created the layer that will poll every ten milliseconds of our metrics registry. Now, we need to provide our infrastructure metrics regarding our logs. We can do this with the logMetrics value provided by the zio-logging library:

val metricsLogger: ZLayer[Unit, IOException, PrometheusPublisher] =
  logMetrics  ++ prometheusConnector

And our application will integrate to use this layer as well and to print our metrics:

val app: ZIO[PrometheusPublisher, Any, Unit] = for {
  - <- ZIO.logTrace("This is a trace message")
  _ <- ZIO.logDebug("This is a debug message")
  _ <- ZIO.logInfo("This is an info message")
  _ <- ZIO.logWarning("This is a warning message")
  _ <- ZIO.logError("This is an error message")
  _ <- ZIO.sleep(500.millis)
  metricValues <- ZIO.serviceWithZIO[PrometheusPublisher](_.get)
  _ <- Console.printLine(metricValues)
} yield ()

override def run: ZIO[ZIOAppArgs & Scope, Any, Any] =
  app.provideLayer(
    BaeldungLogger.slf4jLogger >>> BaeldungMetricsLogger.metricsLogger
  )

We use ZIO.sleep to allow our connector to gather the metrics, and then we get the values from the PrometheusPublisher.

We can check the output to make sure our application works as expected:

ZIO Logging metrics

Zio_log_total is the name of the default counter of the library, but we can define our own with a custom label. Let’s add to our example one:

val metricsLogger: ZLayer[Unit, IOException, PrometheusPublisher] =
  logMetrics ++ logMetricsWith(
    "custom_log_counter",
    "log_level"
  ) ++ prometheusConnector

We have named our counter custom_log_counter, and the label of the logging level is log_level. When we rerun our application, we’ll notice our counter as well:

ZIO Logging metrics with custom counter

7. Conclusion

In this article, we’ve learned how to use the zio-logging library, customize our logs, and set log levels. We’ve also used different loggers, such as console and file logger, and integrated with SLF4J and non-ZIO libraries.

The code backing this article is available on GitHub. Once you're logged in as a Baeldung Pro Member, start learning and coding on the project.