The god of poetry (also battles, fights and other side effects)
Odin library enables functional approach to logging in Scala applications with reasoning and performance as the top priorities.
- Each effect is suspended within the polymorphic
F[_]
- Context is a first-class citizen. Logger is structured by default, no more
TheadLocal
MDCs - Programmatically configurable. Scala is the perfect language for describing configs
- Position tracing implemented with macro instead of reflection considerably boosts the performance
- Own performant logger backends for console and log files
- Composable loggers to bring different loggers together with
Monoid[Logger[F]]
- Interop with SLF4J
Standing on the shoulders of cats-effect
type classes, Odin abstracts away from concrete effect types, allowing
users to decide what they feel comfortable with: IO
, ZIO
, monix.Task
, ReaderT
etc. The choice is yours.
Odin is published to Maven Central and cross-built for Scala 2.12 and 2.13. Add the following lines to your build:
libraryDependencies ++= Seq(
"com.github.valskalla" %% "odin-core",
"com.github.valskalla" %% "odin-json", //to enable JSON formatter if needed
"com.github.valskalla" %% "odin-extras" //to enable additional features if needed (see docs)
).map(_ % "0.13.0")
Using IOApp
:
import cats.effect.{IO, IOApp}
import io.odin._
object Simple extends IOApp.Simple {
val logger: Logger[IO] = consoleLogger()
def run: IO[Unit] = {
logger.info("Hello world")
}
}
Once application starts, it prints:
2019-11-25T22:00:51 [ioapp-compute-0] INFO io.odin.examples.HelloWorld.run:15 - Hello world
Check out examples directory for more
Some time could be saved by using the effect-predefined variants of Odin. There are options for ZIO and Monix users:
//ZIO
libraryDependencies += "com.github.valskalla" %% "odin-zio" % "0.13.0"
//or Monix
libraryDependencies += "com.github.valskalla" %% "odin-monix" % "0.13.0"
Use corresponding import to get an access to the loggers:
import io.odin.zio._
//or
import io.odin.monix._
- Logger interface
- Render
- Console logger
- Formatter
- Minimal level
- File logger
- Async logger
- Class and enclosure routing
- Loggers composition
- Constant context
- Contextual effects
- Secret Context
- Contramap and filter
- ToThrowable
- Testing logger
- Extras
- SL4FJ bridge
- Benchmarks
Odin's logger interface looks like following:
trait Logger[F[_]] {
def trace[M](msg: => M)(implicit render: Render[M], position: Position): F[Unit]
def trace[M, E](msg: => M, t: E)(implicit render: Render[M], tt: ToThrowable[E], position: Position): F[Unit]
def trace[M](msg: => M, ctx: Map[String, String])(implicit render: Render[M], position: Position): F[Unit]
def trace[M, E](msg: => M, ctx: Map[String, String], t: E)(implicit render: Render[M], tt: ToThrowable[E], position: Position): F[Unit]
//continues for each different log level
}
Each method returns F[Unit]
, so most of the time effects are suspended in the context of F[_]
.
It's important to keep in memory that effects like IO
, ZIO
, Task
etc are lazily evaluated, therefore calling
the logger methods isn't enough to emit the actual log. User has to to combine log operations with the rest of code
using plead of options: for ... yield
comprehension, flatMap/map
or >>/*>
operators from cats library.
Particularly interesting are the implicit arguments: Position
, Render[M]
, and ToThrowable[E]
.
Position
class carries the information about invocation site: owning enclosure, package name, current line.
It's generated in compile-time using Scala macro, so cost of position tracing in runtime is close to zero.
Logger's methods are also polymorphic for messages. Users might log every type M
that satisfies Record
constraint:
- It has implicit
Render[M]
instance that describes how to convert value of typeM
toString
- Or it has implicit
cats.Show[M]
instance in scope
By default, Odin provides Render[String]
instance out of the box as well as Render.fromToString
method to construct
instances by calling the standard method .toString
on type M
:
import io.odin.meta.Render
case class Log(s: String, i: Int)
object Log {
implicit val render: Render[Log] = Render.fromToString
}
The most common logger to use:
import io.odin._
import cats.effect.IO
import cats.effect.unsafe.IORuntime
//required for evaluation of IO later. IOApp provides it out of the box
implicit val ioRuntime: IORuntime = IORuntime.global
val logger: Logger[IO] = consoleLogger[IO]()
Now to the call:
//doesn't print anything as the effect is suspended in IO
logger.info("Hello?")
// res0: IO[Unit] = Map(
// ioe = FlatMap(
// ioe = IO(...),
// f = io.odin.loggers.DefaultLogger$$Lambda$10493/1420441894@3f9a6e97,
// event = cats.effect.tracing.TracingEvent$StackTrace
// ),
// f = cats.effect.IO$$Lambda$10494/3950838@2115afaa,
// event = cats.effect.tracing.TracingEvent$StackTrace
// )
//prints "Hello world" to the STDOUT.
//Although, don't use `unsafeRunSync` in production unless you know what you're doing
logger.info("Hello world").unsafeRunSync()
// 2021-09-18T18:52:09,672 [io-compute-1] INFO repl.MdocSession.App#res1:62 - Hello world
All messages of level WARN
and higher are routed to the STDERR while messages with level INFO
and below go to the STDOUT.
consoleLogger
has the following definition:
def consoleLogger[F[_]: Sync](
formatter: Formatter = Formatter.default,
minLevel: Level = Level.Trace
): Logger[F]
It's possible to configure minimal level of logs to print (TRACE by default) and formatter that's used to print it.
In Odin, formatters are responsible for rendering LoggerMessage
data type into String
.
LoggerMessage
carries the information about :
- Level of the log
- Context
- Optional exception
- Timestamp
- Invocation position
- Suspended message
Formatter's definition is straightforward:
trait Formatter {
def format(msg: LoggerMessage): String
}
odin-core provides the Formatter.default
and Formatter.colorful
that prints information in a nicely structured manner:
(logger.info("No context") *> logger.info("Some context", Map("key" -> "value"))).unsafeRunSync()
// 2021-09-18T18:52:09,704 [io-compute-1] INFO repl.MdocSession.App#res2:68 - No context
// 2021-09-18T18:52:09,704 [io-compute-1] INFO repl.MdocSession.App#res2:68 - Some context - key: value
The latter adds a bit of colors to the default formatter:
Library odin-json enables output of logs as newline-delimited JSON records:
import io.odin.json.Formatter
val jsonLogger = consoleLogger[IO](formatter = Formatter.json)
Now messages printed with this logger will be encoded as JSON string using circe:
jsonLogger.info("This is JSON").unsafeRunSync()
// {"level":"INFO","message":"This is JSON","context":{},"exception":null,"position":"repl.MdocSession.App#res3:83","thread_name":"io-compute-0","timestamp":"2021-09-18T18:52:09,739"}
Beside copy-pasting the existing formatter to adjust it for one's needs, it's possible to do the basic customization by relying on Formatter.create
:
object Formatter {
def create(throwableFormat: ThrowableFormat, positionFormat: PositionFormat, colorful: Boolean, printCtx: Boolean): Formatter
}
ThrowableFormat
allows to tweak the rendering of exceptions, specifically indentation and stack depth.PositionFormat
allows to tweak the rendering of position.colorful
flag enables logs highlighting.printCtx
flag enables log context printer
It's possible to set minimal level for log messages to i.e. disable debug logs in production mode:
//either by constructing logger with specific parameter
val minLevelInfoLogger = consoleLogger[IO](minLevel = Level.Info)
//or modifying the existing one
val minLevelWarnLogger = logger.withMinimalLevel(Level.Warn)
Those lines won't print anything:
(minLevelInfoLogger.debug("Invisible") *> minLevelWarnLogger.info("Invisible too")).unsafeRunSync()
Performance wise, it'll cost only the allocation of F.unit
value.
Another backend that Odin provides by default is the basic file logger:
def fileLogger[F[_]: Sync: Clock](
fileName: String,
formatter: Formatter = Formatter.default,
minLevel: Level = Level.Trace,
openOptions: Seq[OpenOption] = Seq.empty
): Resource[F, Logger[F]]
It's capable only of writing to the single file by path fileName
. One particular detail is worth to mention here:
return type. Odin tries to guarantee safe allocation and release of file resource. Because of that fileLogger
returns
Resource[F, Logger[F]]
instead of Logger[F]
:
val file = fileLogger[IO]("log.log")
file.use { logger =>
logger.info("Hello file")
}.unsafeRunSync() //Mind that here file resource is free, all buffers are flushed and closed
Usual pattern here is to compose and allocate such resources during the start of your program and wrap execution of
the logic inside of .use
block.
The openOptions
parameter allows overriding the default OpenOption
parameters when creating a file.
See java.nio.file.Files
for details. The default behavior is to create a file or truncate if it exists.
In case if the directories in the file path do not exist in the file system, Odin will try to create them.
Important notice: this logger doesn't buffer and tries to flush to the file on each log due to the safety guarantees.
Consider to use asyncFileLogger
version with almost the same signature (except the Concurrent[F]
constraint)
to achieve the best performance.
Beside the basic file logger, Odin provides a rolling one to rollover log files. Rollover can be triggered by exceeding a configured log file size and/or timer, whichever happens first if set:
def rollingFileLogger[F[_]: Async](
fileNamePattern: LocalDateTime => String,
rolloverInterval: Option[FiniteDuration],
maxFileSizeInBytes: Option[Long],
formatter: Formatter = Formatter.default,
minLevel: Level = Level.Trace
): Resource[F, Logger[F]]
Similar to the original file logger, rolling logger is also a Resource
, but this time it also acquires a fiber
that checks for rolling conditions each 100 milliseconds.
The fileNamePattern
parameter is used each time new log file is created to generate a file name given current datetime.
The easiest way to construct it is to use file
interpolator from io.odin.config
:
import io.odin.config._
import java.time.LocalDateTime
val fileNamePattern = file"/var/log/$year-$month-$day-$hour-$minute-$second.log"
// fileNamePattern: LocalDateTime => String = io.odin.config.package$FileNamePatternInterpolator$$$Lambda$10522/480906010@5e64f60b
val fileName = fileNamePattern(LocalDateTime.now)
// fileName: String = "/var/log/2021-09-18-18-52-09.log"
Interpolator placeholders used above are provided with io.odin.config
package as well:
year.extract(LocalDateTime.now)
// res6: String = "2021"
month.extract(LocalDateTime.now)
// res7: String = "09"
hour.extract(LocalDateTime.now)
// res8: String = "18"
minute.extract(LocalDateTime.now)
// res9: String = "52"
second.extract(LocalDateTime.now)
// res10: String = "09"
All the placeholders are padded with 0
to contain at least two digits. It's also possible to include any string
variable.
Important notice: just like the basic file logger, this logger doesn't buffer and tries to flush to the current file on
each log due to the safety guarantees. Consider to use asyncRollingFileLogger
version with almost the same signature to
achieve the best performance.
To achieve the best performance with Odin, it's best to use AsyncLogger
with the combination of any other logger.
It uses ConcurrentQueue[F]
from Monix as the buffer that is asynchronously flushed each fixed time period.
Conversion of any logger into async one is straightforward:
import cats.effect.Resource
import io.odin.syntax._ //to enable additional implicit methods
val asyncLoggerResource: Resource[IO, Logger[IO]] = consoleLogger[IO]().withAsync()
Resource[F, Logger[F]]
is used to properly initialize the log buffer and flush it on the release. Therefore, use of
async logger shall be done inside of Resource.use
block:
//queue will be flushed on release even if flushing timer didn't hit the mark yet
asyncLoggerResource.use(logger => logger.info("Async info")).unsafeRunSync()
// 2021-09-18T18:52:09,933 [io-compute-1] INFO repl.MdocSession.App#res11:166 - Async info
Package io.odin.syntax._
also pimps the Resource[F, Logger[F]]
type with the same .withAsync
method to use
in combination with i.e. fileLogger
. Actually, asyncFileLogger
implemented exactly in this manner.
The immediate gain is the acquire/release safety provided by Resource
abstraction for combination of FileLogger
and AsyncLogger
,
as well as controllable in-memory buffering for logs before they're flushed down the stream.
Definition of withAsync
is following:
def withAsync(
timeWindow: FiniteDuration = 1.millis,
maxBufferSize: Option[Int] = None
)(implicit F: Async[F]): Resource[F, Logger[F]]
Following parameters are configurable if default ones don't fit:
- Time period between flushed, default is 1 millisecond
- Maximum underlying buffer size, by default buffer is unbounded.
Users have an option to use different loggers for different classes, packages and even function enclosures. I.e. it could be applied to silence some particular class applying stricter minimal level requirements.
Class based routing works with the help of classOf
function:
import io.odin.config._
case class Foo[F[_]](logger: Logger[F]) {
def log: F[Unit] = logger.info("foo")
}
case class Bar[F[_]](logger: Logger[F]) {
def log: F[Unit] = logger.info("bar")
}
val routerLogger: Logger[IO] =
classRouting[IO](
classOf[Foo[IO]] -> consoleLogger[IO]().withMinimalLevel(Level.Warn),
classOf[Bar[IO]] -> consoleLogger[IO]().withMinimalLevel(Level.Info)
).withNoopFallback
Method withNoopFallback
defines that any message that doesn't match the router is nooped.
Use withFallback(logger: Logger[F])
to route all non-matched messages to specific logger.
Enclosure based routing is more flexible but might be error-prone as well:
val enclosureLogger: Logger[IO] =
enclosureRouting(
"io.odin.foo" -> consoleLogger[IO]().withMinimalLevel(Level.Warn),
"io.odin.bar" -> consoleLogger[IO]().withMinimalLevel(Level.Info),
"io.odin" -> consoleLogger[IO]()
)
.withNoopFallback
def zoo: IO[Unit] = enclosureLogger.debug("Debug")
def foo: IO[Unit] = enclosureLogger.info("Never shown")
def bar: IO[Unit] = enclosureLogger.warn("Warning")
Routing is done based on the string matching with the greedy first match logic, hence the most specific routes should always appear on the top, otherwise they might be ignored.
Odin defines Monoid[Logger[F]]
instance to combine multiple loggers into a single one that broadcasts
the logs to the underlying loggers:
import cats.syntax.all._
val combinedLogger: Resource[IO, Logger[IO]] = consoleLogger[IO]().withAsync() |+| fileLogger[IO]("log.log")
Example above demonstrates that it would work even for Resource
based loggers. combinedLogger
prints messages both
to console and to log.log file.
To append some predefined context to all the messages of the logger, use withConstContext
syntax to construct such logger:
import io.odin.syntax._
consoleLogger[IO]()
.withConstContext(Map("predefined" -> "context"))
.info("Hello world").unsafeRunSync()
// 2021-09-18T18:52:09,977 [io-compute-1] INFO repl.MdocSession.App#res12:230 - Hello world - predefined: context
Some effects carry an extractable information that could be automatically injected as the context to each log message.
An example is ReaderT[F[_], Env, A]
monad, where value of type Env
contains some additional information to log.
Odin allows to build a logger that extracts this information from effect and put it as the context:
import io.odin.loggers._
import cats.data.ReaderT
case class Env(ctx: Map[String, String])
object Env {
//it's necessary to describe how to extract context from env
implicit val hasContext: HasContext[Env] = new HasContext[Env] {
def getContext(env: Env): Map[String, String] = env.ctx
}
}
type M[A] = ReaderT[IO, Env, A]
consoleLogger[M]()
.withContext
.info("Hello world")
.run(Env(Map("env" -> "ctx")))
.unsafeRunSync()
// 2021-09-18T18:52:10,22 [io-compute-1] INFO repl.MdocSession.App#res13:258 - Hello world - env: ctx
Odin automatically derives required type classes for each type F[_]
that has Ask[F, E]
defined, or in other words
for all the types that allow F[A] => F[E]
.
If this constraint isn't satisfied, it's required to manually provide an instance for WithContext
type class:
/**
* Resolve context stored in `F[_]` effect
*/
trait WithContext[F[_]] {
def context: F[Map[String, String]]
}
Sometimes it's necessary to hide sensitive information from the logs, would it be user identification data, passwords or anything else.
Odin can hash the values of predefined context keys to preserve the fact of information existence, but exact value becomes unknown:
import io.odin.syntax._
consoleLogger[IO]()
.withSecretContext("password")
.info("Hello, username", Map("password" -> "qwerty"))
.unsafeRunSync() //rendered context contains first 6 symbols of SHA-1 hash of password
// 2021-09-18T18:52:10,30 [io-compute-1] INFO repl.MdocSession.App#res14:271 - Hello, username - password: secret:b1b377
To modify or filter log messages before they're written, use corresponding combinators contramap
and filter
:
import io.odin.syntax._
consoleLogger[IO]()
.contramap(msg => msg.copy(message = msg.message.map(_ + " World")))
.info("Hello")
.unsafeRunSync()
// 2021-09-18T18:52:10,34 [io-compute-1] INFO repl.MdocSession.App#res15:283 - Hello World
consoleLogger[IO]()
.filter(msg => msg.message.value.size < 10)
.info("Very long messages are discarded")
.unsafeRunSync()
The closer look on the exception logging reveals that there is no requirement for an error to be an instance of Throwable
:
def trace[M, E](msg: => M, t: E)(implicit render: Render[M], tt: ToThrowable[E], position: Position): F[Unit]
def trace[M](msg: => M, ctx: Map[String, String])(implicit render: Render[M], position: Position): F[Unit]
def trace[M, E](msg: => M, ctx: Map[String, String], t: E)(implicit render: Render[M], tt: ToThrowable[E], position: Position): F[Unit]
Given the implicit constraint ToThrowable
it's possible to log any error of type E
as far as it satisfies this constraint by providing
an implicit instance of the following interface:
/**
* Type class that converts a value of type `E` into Throwable
*/
trait ToThrowable[E] {
def throwable(e: E): Throwable
}
Odin provides an instance of ToThrowable
for each E <: Throwable
out of the box. A good practice is to keep such implicits in the companion object
of a corresponding type error type E
.
One of the main benefits of a polymorphic Logger[F]
interface that exposes F[Unit]
methods is a simple way to
test that the application correctly writes logs.
Since every operation represents a value instead of no-op side effect, it's possible to check those values in specs.
Odin provides a class WriterTLogger[F]
out of the box to test logging using WriterT
monad.
Check out examples for more information.
The odin-extras
module provides additional functionality: ConditionalLogger, Render derivation, etc.
- Add following dependency to your build:
libraryDependencies += "com.github.valskalla" %% "odin-extras" % "0.13.0"
In some scenarios, it is necessary to have different logging levels depending on the result of the execution.
For example, the default log level can be Info
, but once an error is raised, previous messages with log level Debug
will be logged as well.
Example:
import cats.effect.Async
import io.odin.Logger
import io.odin.extras.syntax._
case class User(id: String)
class UserService[F[_]](logger: Logger[F])(implicit F: Async[F]) {
import cats.syntax.functor._
import cats.syntax.flatMap._
private val BadSuffix = "bad-user"
def findAndVerify(userId: String): F[Unit] =
logger.withErrorLevel(Level.Debug) { log =>
for {
_ <- log.debug(s"Looking for user by id [$userId]")
user <- findUser(userId)
_ <- log.debug(s"Found user $user")
_ <- verify(user)
_ <- log.info(s"User found and verified $user")
} yield ()
}
private def findUser(userId: String): F[User] =
F.delay(User(s"my-user-$userId"))
private def verify(user: User): F[Unit] =
F.whenA(user.id.endsWith(BadSuffix)) {
F.raiseError(new RuntimeException("Bad User"))
}
}
val service = new UserService[IO](consoleLogger[IO](minLevel = Level.Info))
// service: UserService[IO] = repl.MdocSession$App$UserService@28936e70
service.findAndVerify("good-user").attempt.unsafeRunSync()
// 2021-09-18T18:52:10,68 [io-compute-0] INFO repl.MdocSession.App#UserService#findAndVerify:322 - User found and verified User(my-user-good-user)
// res17: Either[Throwable, Unit] = Right(value = ())
service.findAndVerify("bad-user").attempt.unsafeRunSync()
// 2021-09-18T18:52:10,73 [io-compute-1] DEBUG repl.MdocSession.App#UserService#findAndVerify:318 - Looking for user by id [bad-user]
// 2021-09-18T18:52:10,73 [io-compute-1] DEBUG repl.MdocSession.App#UserService#findAndVerify:320 - Found user User(my-user-bad-user)
// res18: Either[Throwable, Unit] = Left(
// value = java.lang.RuntimeException: Bad User
// )
io.odin.extras.derivation.render
provides a Magnolia-based derivation of the Render
type class. If you're on Scala 2, please make sure to have scala-reflect
in your dependencies, for example using:
libraryDependencies += "org.scala-lang" % "scala-reflect" % scalaVersion.value
On Scala 3, Magnolia is based on scala.deriving.Mirror
and hence doesn't need any additional dependency.
The derivation can be configured via annotations:
- @rendered(includeMemberName = false)
The member names will be omitted:
@rendered(includeMemberName = false)
case class ApiConfig(uri: String, apiKey: String)
- @hidden
Excludes an annotated member from the output:
case class ApiConfig(uri: String, @hidden apiKey: String)
- @secret
Replaces the value of an annotated member with <secret>
:
case class ApiConfig(uri: String, @secret apiKey: String)
- @length
Shows only first N elements of the iterable. Works exclusively with subtypes of Iterable
:
case class ApiConfig(uri: String, @hidden apiKey: String, @length(2) environments: List[String])
Example:
import io.odin.syntax._
import io.odin.extras.derivation._
import io.odin.extras.derivation.render._
case class ApiConfig(
uri: String,
@hidden apiKey: String,
@secret apiSecret: String,
@length(2) environments: List[String]
)
val config = ApiConfig("https://localhost:8080", "api-key", "api-secret", List("test", "dev", "pre-prod", "prod"))
// config: ApiConfig = ApiConfig(
// uri = "https://localhost:8080",
// apiKey = "api-key",
// apiSecret = "api-secret",
// environments = List("test", "dev", "pre-prod", "prod")
// )
println(render"API config $config")
// API config ApiConfig(uri = https://localhost:8080, apiSecret = <secret>, environments = List(test, dev)(2 more))
Please note that by differences of magnolia itself, the derivation on Scala 2 and Scala 3 might yield slightly different results. One known difference is that for Scala 3, value classes are currently not unwrapped as they're on Scala 2.
In case if some dependencies in the project use SL4J as a logging API, it's possible to provide Odin logger as a backend. It requires a two-step setup:
- Add following dependency to your build:
libraryDependencies += "com.github.valskalla" %% "odin-slf4j" % "0.13.0"
- Create Scala class
ExternalLogger
somewhere in the project:
import cats.effect.{Sync, IO}
import cats.effect.std.Dispatcher
import cats.effect.unsafe.implicits.global
import io.odin._
import io.odin.slf4j.OdinLoggerBinder
//effect type should be specified inbefore
//log line will be recorded right after the call with no suspension
class ExternalLogger extends OdinLoggerBinder[IO] {
implicit val F: Sync[IO] = IO.asyncForIO
implicit val dispatcher: Dispatcher[IO] = Dispatcher[IO].allocated.unsafeRunSync()._1
val loggers: PartialFunction[String, Logger[IO]] = {
case "some.external.package.SpecificClass" =>
consoleLogger[IO](minLevel = Level.Warn) //disable noisy external logs
case _ => //if wildcard case isn't provided, default logger is no-op
consoleLogger[IO]()
}
}
- Create
StaticLoggerBinder.java
class in the packageorg.slf4j.impl
with the following content:
package org.slf4j.impl;
import io.odin.slf4j.ExternalLogger;
public class StaticLoggerBinder extends ExternalLogger {
public static String REQUESTED_API_VERSION = "1.7";
private static final StaticLoggerBinder _instance = new StaticLoggerBinder();
public static StaticLoggerBinder getSingleton() {
return _instance;
}
}
Latter is required for SL4J API to load it in runtime and use as a binder for LoggerFactory
. All the logic is
encapsulated in ExternalLogger
class, so the Java part here is required only for bootstrapping.
Partial function is used as a factory router to load correct logger backend. On undefined case the no-op logger is provided by default,
so no logs are recorded.
This bridge doesn't support MDC.
Since v0.13 It's possible to use SLF4J-compatible logger as the backend for Odin's Logger[F]
.
- Add following dependency to your build:
libraryDependencies += "com.github.valskalla" %% "odin-slf4j" % "0.13.0"
- Construct Odin logger based on SLF4J:
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import io.odin._
import io.odin.slf4j.Slf4jLogger
import org.slf4j.LoggerFactory
//feel free to load any logger that suits you
val logger: Logger[IO] = Slf4jLogger[IO](logger = LoggerFactory.getLogger("OdinSlf4jLogger"))
// logger: Logger[IO] = io.odin.slf4j.Slf4jLogger@28d61470
logger.info("Hello world").unsafeRunSync()
Odin is one of the fastest JVM tracing loggers in the wild. By relying on Scala macro machinery instead of stack trace inspection for deriving callee enclosure and line number, Odin achieves quite impressive throughput numbers comparing with existing mature solutions.
Following benchmark results reflect comparison of:
- log4j file loggers with enabled tracing
- Odin file loggers
- scribe file loggers
Lower number is better:
-- log4j
[info] Benchmark Mode Cnt Score Error Units
[info] Log4jBenchmark.asyncMsg avgt 25 23316.067 ± 1179.658 ns/op
[info] Log4jBenchmark.msg avgt 25 12421.523 ± 1773.842 ns/op
[info] Log4jBenchmark.tracedMsg avgt 25 24754.219 ± 4001.198 ns/op
-- odin sync
[info] Benchmark Mode Cnt Score Error Units
[info] FileLoggerBenchmarks.msg avgt 25 6264.891 ± 510.206 ns/op
[info] FileLoggerBenchmarks.msgAndCtx avgt 25 5903.032 ± 243.277 ns/op
[info] FileLoggerBenchmarks.msgCtxThrowable avgt 25 11868.172 ± 275.807 ns/op
-- odin async
[info] Benchmark Mode Cnt Score Error Units
[info] AsyncLoggerBenchmark.msg avgt 25 532.986 ± 126.094 ns/op
[info] AsyncLoggerBenchmark.msgAndCtx avgt 25 477.833 ± 87.207 ns/op
[info] AsyncLoggerBenchmark.msgCtxThrowable avgt 25 292.481 ± 34.979 ns/op
-- scribe
[info] Benchmark Mode Cnt Score Error Units
[info] ScribeBenchmark.asyncMsg avgt 25 124.507 ± 35.444 ns/op
[info] ScribeBenchmark.asyncMsgCtx avgt 25 122.867 ± 6.833 ns/op
[info] ScribeBenchmark.msg avgt 25 1105.457 ± 77.251 ns/op
[info] ScribeBenchmark.msgAndCtx avgt 25 1235.908 ± 21.112 ns/op
Hardware:
MacBook Pro (13-inch, 2018)
2.3 GHz Quad-Core Intel Core i5
16 GB 2133 MHz LPDDR3
Odin outperforms log4j by the order of magnitude, although scribe does it even better. Mind that due to safety guarantees default file logger in Odin is flushed after each record, so it's recommended to use it in combination with async logger to achieve the maximum performance.
Feel free to open an issue, submit a Pull Request or ask in the Gitter channel. We strive to provide a welcoming environment for everyone with good intentions.
Also, don't hesitate to give it a star and spread the word to your friends and colleagues.
Odin is maintained by Sergey Kolbasov and Aki Huttunen.
- scribe logging framework as a source of performance optimizations and inspiration
- sourcecode is the library for position tracing in compile-time
- cats-effect as a repository of all the nice type classes to describe effects
- sbt-ci-release for the smooth experience with central Maven releases from CI
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this software except in compliance with the License.
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.