Functional logging in Scala with Odin using Cats-Effect, featuring structured logs, async logging, file loggers, minimal levels, and contextual effects.
Functional logging in Scala with Odin using Cats-Effect, featuring structured logs, async logging, file loggers, minimal levels, and contextual effects.
In modern Scala applications, logging plays a crucial role in tracking events, monitoring performance, and debugging issues. However, traditional logging libraries can introduce side effects and complexity that don’t align with functional programming principles. Odin addresses this challenge by offering a purely functional approach to logging, with a focus on performance, composability, and structured logs. In this detailed guide, we’ll walk through Odin’s key features, explain its concepts, and show how to use it with Cats-Effect.
Odin provides a functional interface to logging with several key benefits:
trait Logger[F[_]] {
def trace(msg: String): F[Unit]
def debug(msg: String): F[Unit]
def info(msg: String): F[Unit]
def warn(msg: String): F[Unit]
def error(msg: String, t: Throwable = null): F[Unit]
}ScalaThe core abstraction in Odin is the Logger[F] interface, where F is an effect type, such as IO from Cats-Effect. This interface provides methods to log messages at different levels:
The simplest way to start using Odin is with a console logger.
import cats.effect.{IO, IOApp}
import io.odin._
val logger: Logger[IO] = consoleLogger[IO]()
object SimpleApp extends IOApp.Simple {
def run: IO[Unit] = logger.info("Application started")
}ScalaYou can log messages to a file or use a rolling file logger to rotate logs.
import java.nio.file.Paths
import io.odin._
val fileLogger: Logger[IO] = fileLogger[IO](Paths.get("logs/app.log"))
val rollingLogger: Logger[IO] = rollingFileLogger[IO](
path = Paths.get("logs/rolling.log"),
maxSizeInBytes = 1024 * 1024, // 1MB
maxFiles = 5
)ScalaOdin allows you to format logs with built-in and custom formatters.
val logger: Logger[IO] = consoleLogger[IO](
formatter = Formatter.default
)Scalaval jsonLogger: Logger[IO] = consoleLogger[IO](
formatter = Formatter.json
)Scalaval customLogger: Logger[IO] = consoleLogger[IO](
formatter = Formatter.default.withoutColors.copy(timestampFormat = Some("HH:mm:ss"))
)ScalaYou can control which logs are emitted by setting the minimal log level.
val logger: Logger[IO] = consoleLogger[IO](minLevel = Level.Warn)ScalaYou can combine multiple loggers, such as console and file loggers, into a single composite logger.
val combinedLogger: Logger[IO] = logger[IO](consoleLogger[IO](), fileLogger[IO](Paths.get("logs/app.log")))ScalaAsynchronous logging ensures that logging doesn’t block your application’s main flow.
val asyncLogger: Logger[IO] = asyncLogger[IO](consoleLogger[IO]())ScalaYou can route logs based on the enclosing class or function.
def getLoggerForClass[T](cls: Class[T]): Logger[IO] =
consoleLogger[IO]().withMinimalLevel(Level.Info)ScalaOdin supports adding constant context to logs, such as service names or instance IDs.
val loggerWithContext: Logger[IO] = logger.withConstantContext(Map("service" -> "user-service"))
loggerWithContext.info("Starting the service")ScalaYou can also add dynamic context using contextual effects.
def logUserAction(userId: String): IO[Unit] =
logger.contextual(Map("userId" -> userId)).info("User action processed")ScalaOdin allows you to mask sensitive data using secret contexts.
val loggerWithSecret: Logger[IO] = logger.withSecretContext(Map("apiKey" -> "12345"))
loggerWithSecret.info("API call executed")ScalaYou can transform logs with contramap or filter logs based on conditions.
val filteredLogger: Logger[IO] = logger.filter(msg => !msg.message.contains("DEBUG"))ScalaToThrowableOdin provides the toThrowable method for logging exceptions as structured messages.
val loggerWithThrowable: Logger[IO] = logger.toThrowable
loggerWithThrowable.error("Exception occurred", new RuntimeException("Boom!"))ScalaOdin also provides a TestingLogger to validate logs in unit tests.
import io.odin.loggers.TestingLogger
val testingLogger = TestingLogger[IO]()
for {
_ <- testingLogger.info("Test message")
logs <- testingLogger.get
} yield assert(logs.exists(_.message == "Test message"))ScalaOdin allows you to log conditionally, based on runtime conditions.
def logIf(condition: Boolean): IO[Unit] =
logger.ifEnabled(Level.Debug)(logger.debug("Conditional log"))ScalaYou can enrich logs with additional context using extras.
val loggerWithExtras: Logger[IO] = logger.withExtras(Map("env" -> "production"))ScalaOdin is optimized for performance with minimal impact on your application. Asynchronous logging and buffered outputs ensure that logs are emitted efficiently. Benchmarks available in the repository demonstrate Odin’s performance compared to other logging libraries.
Odin provides a functional and performant approach to logging for Scala applications, perfectly aligning with Cats-Effect. With structured logging, context support, and minimal configuration, Odin empowers developers to build robust, observable systems. Whether you need console logs for local debugging or rolling file logs for production, Odin offers a flexible and composable solution.
If you’re building functional applications in Scala, Odin is a perfect fit for your logging needs. Try it out and experience the power of functional logging!

10+ years building distributed systems and fintech platforms. I write about the things I actually debug at work — the messy, non-obvious parts that don't make it into official docs.
Engineering deep dives on Scala, Java, Rust, and AI Systems. Written by a senior engineer who builds real fintech systems.
TOPICS
© 2026 prabhat.dev