ADT support

slog4s provides built-in support for automatic derivation of LogEncoder typeclass, which allows one to use case class or sealed trait as additional arguments for logging. It supports both fully automatic derivation, and semi automatic derivation.

Installation

libraryDependencies ++= Seq("com.avast" %% "slog4s-generic" % "0.6.1+7-cb5b3c2d-SNAPSHOT") 

Example

Suppose we have following case class:

case class Foo(fooValue: String)
case class Bar(barValue: Int, foo: Foo)

val bar = Bar(42, Foo("Hello!"))

Automatic derivation

With automatic derivation you just need to include proper import.

import slog4s.generic.auto._
logger.info
      .withArg("bar", bar)
      .log("Logging bar instance")
      .unsafeRunSync()

Output:

{
  "@timestamp" : "2023-02-24T17:57:18.704+01:00",
  "@version" : "1",
  "bar" : {
    "barValue" : 42,
    "foo" : {
      "fooValue" : "Hello!"
    }
  },
  "file" : "adt.md",
  "level" : "INFO",
  "level_value" : 20000,
  "line" : 43,
  "logger_name" : "test-logger",
  "message" : "Logging bar instance",
  "thread_name" : "Thread-17"
}

Semi automatic derivation

Sometimes it might be more convenient to have LogEncoder instance have defined directly in a code. You can use semi automatic derivation for that:

import slog4s.generic.semi._

object Foo {
  implicit val fooEncoder: LogEncoder[Foo] = logEncoder[Foo]
}

object Bar {
  implicit val barEncoder: LogEncoder[Bar] = logEncoder[Bar]
}

logger.info
      .withArg("bar", bar)
      .log("Logging bar instance")
      .unsafeRunSync()

Output:

{
  "@timestamp" : "2023-02-24T17:57:18.725+01:00",
  "@version" : "1",
  "bar" : {
    "barValue" : 42,
    "foo" : {
      "fooValue" : "Hello!"
    }
  },
  "file" : "adt.md",
  "level" : "INFO",
  "level_value" : 20000,
  "line" : 65,
  "logger_name" : "test-logger",
  "message" : "Logging bar instance",
  "thread_name" : "Thread-17"
}

Map support

There is built-in support for representing Maps. Generally we try to represent them as a map in the target encoding (think JSON dictionary) whenever possible. However it might be impossible for cases where a key is not a primitive type or a String. So there is a simple rule: if the key implements cats.Show typeclass, we represent the whole Map as a real map/dictionary. Otherwise we represent it as an array of key/value pairs.

import cats.Show
import cats.instances.all._

case class MyKey(value: String)
object MyKey {
  implicit val showInstance: Show[MyKey] = _.value
}

logger.info
      .withArg("foo", Map("key" -> "value"))
      .withArg("bar", Map(42 -> "value"))
      .withArg("baz", Map(MyKey("my_key") -> "value"))
      .log("Hello world")
      .unsafeRunSync()

Output:

{
  "@timestamp" : "2023-02-24T17:57:18.9+01:00",
  "@version" : "1",
  "bar" : {
    "42" : "value"
  },
  "baz" : {
    "my_key" : "value"
  },
  "file" : "adt.md",
  "foo" : {
    "key" : "value"
  },
  "level" : "INFO",
  "level_value" : 20000,
  "line" : 90,
  "logger_name" : "test-logger",
  "message" : "Hello world",
  "thread_name" : "Thread-17"
}
case class OtherKey(x: Int, y: Int)

logger.info
      .withArg("foo", Map(OtherKey(1,2) -> "value"))
      .log("Hello world")
      .unsafeRunSync()

Output:

{
  "@timestamp" : "2023-02-24T17:57:18.903+01:00",
  "@version" : "1",
  "file" : "adt.md",
  "foo" : [
    [
      {
        "x" : 1,
        "y" : 2
      },
      "value"
    ]
  ],
  "level" : "INFO",
  "level_value" : 20000,
  "line" : 102,
  "logger_name" : "test-logger",
  "message" : "Hello world",
  "thread_name" : "Thread-17"
}