Skip to content

Logging Framework optimized for use with the ELK stack.

License

Notifications You must be signed in to change notification settings

Galeria-Kaufhof/ets-logging

Repository files navigation

A logging framework that brings compile-time safety to your log statements. Specify which data is encodable and how exactly the encoding should take place. Then the compiler prevents you from creating invalid log statements.

WARNING This library is currently in the prototyping phase. Don't expect working code and don't even try to use it!

Basic Idea

Add this to your build.sbt:

libraryDependencies += "de.kaufhof.ets" %% "ets-logging-core" % "0.1.0-SNAPSHOT"

Given some domain objects:

sealed trait Epic extends Product
object Epic {
  case object FeatureA extends Epic
  case object FeatureB extends Epic
}
case class VariantId(value: String)
case class Variant(id: VariantId, name: String)
case class TestClass(a: Int, b: String)

Start defining a set of Keys you wish to use within your logs events:

import de.kaufhof.ets.logging._
import de.kaufhof.ets.logging.syntax._

import java.time.Instant
import java.util.UUID

object StringKeys extends LogKeysSyntax[String] with DefaultStringEncoders {
  val Logger:        Key[Class[_]] =      Key("logger")      .withImplicitEncoder
  val Level:         Key[Level] =         Key("level")       .withImplicitEncoder
  val Message:       Key[String] =        Key("msg")         .withImplicitEncoder
  val Timestamp:     Key[Instant] =       Key("ts")          .withExplicit(Encoder.fromToString)
  val VariantId:     Key[VariantId] =     Key("variantid")   .withExplicit(Encoder[VariantId].by(_.value))
  val VariantName:   Key[String] =        Key("variantname") .withImplicitEncoder
  val SomeUUID:      Key[UUID] =          Key("uuid")        .withImplicitEncoder
  val RandomEncoder: Key[Random] =        Key("randenc")     .withExplicit(Encoder[Random].by(_.nextInt(100)))
  val RandomEval:    Key[Int] =           Key("randeval")    .withImplicitEncoder
  val Epic:          Key[Epic] =          Key("epic")        .withExplicit(Encoder[Epic].by(_.productPrefix))
}

LogKeysSyntax[String] contains a small DSL to setup keys. That includes all neccessary syntax to specify Key s and associate Encoder s to it. Key instances require an id of type string and an Encoder. Encoder s are used to encode values into String as indicated by the type parameter in this case. DefaultStringEncoders is a set of implicit Encoder definitions for the most common types. Some of the most common types are; Int, Long, Double, UUID and so forth. These most common type Encoders facilitate selecting an Encoder for the domain specific Key s. Existing Encoder instances can be used to derive new Encoder instances, like depicted above.

Next provide a configuration combining the previously defined Keys and an optional set of Decomposer instances.

object StringLogConfig extends DefaultLogConfig[String, Unit] with DefaultStringEncoders {
  override type Combined = String
  override def combiner: LogEventCombiner[String, String] = StringLogEventCombiner
  override def appender: Appender = StdOutStringLogAppender
  override def rootLevel: Level = Level.Info

  object Decomposers extends Decomposer2DecomposedImplicits[String] {
    import syntax._
    implicit lazy val variantDecomposer: Decomposer[Variant] = variant =>
      Decomposed(
        Keys.VariantId ~> variant.id,
        Keys.VariantName ~> variant.name
    )
    implicit lazy val epicDecomposer: Decomposer[Epic] = epic => Decomposed(Keys.Epic ~> epic)
  }

  val syntax = ConfigSyntax(StringKeys, Decomposers)
  override def predefKeys: PredefKeys = syntax.Keys
}

Provide a logger instance called log via mixin into your class/trait/object using the above config. Then use it to log different attributes in combination with a message:

object Main extends StringLogConfig.LogInstance {
  val variant: Variant = Variant(VariantId("VariantId"), "VariantName")
  val uuid: UUID = java.util.UUID.fromString("723f03f5-13a6-4e46-bdac-3c66718629df")

  // use standard log methods with severity for the log level and a message
  log.info("test234")
  log.debug("test123") // will be omitted due to configured rootLevel
  // provide additional information with an arbitrary amount of key value pairs, called attributes
  log.error("test345", Keys.VariantId ~> variant.id, Keys.SomeUUID -> uuid)
  // use the generic event method to construct arbitrary log events without any predefined attributes
  log.event(Keys.VariantId ~> variant.id, Keys.SomeUUID -> uuid, Keys.Timestamp ~> Instant.MIN)
  // inputs to encoders are contravariant and therefore directly accept instances of the key-types's subtypes
  log.info("""yay \o/""", Keys.Epic -> Epic.FeatureA)
  // or pass any amount of decomposable objects
  // this requires an implicit decomposer to be in scope
  // then the decomposer will decompose the available attributes for you
  import encoding.string.StringLogConfig.syntax.decomposers._
  log.event(variant)
  // inputs to decomposers are contravariant as well and therefore accept instances of the input-types's subtypes
  log.info("""yay \o/""",  Epic.FeatureA)
}

Also take look into the short self-contained complete compliable example under: test/Main.scala

Possible output for string encoding as shown above could then look like this:

level -> Info | logger -> de.kaufhof.ets.logging.test.Main$README$ | msg -> test234 | ts -> 2019-01-25T19:39:52.594Z
level -> Error | logger -> de.kaufhof.ets.logging.test.Main$README$ | msg -> test345 | ts -> 2019-01-25T19:39:52.629Z | uuid -> 723f03f5-13a6-4e46-bdac-3c66718629df | variantid -> VariantId
logger -> de.kaufhof.ets.logging.test.Main$README$ | ts -> -1000000000-01-01T00:00:00Z | uuid -> 723f03f5-13a6-4e46-bdac-3c66718629df | variantid -> VariantId
epic -> FeatureA | level -> Info | logger -> de.kaufhof.ets.logging.test.Main$README$ | msg -> yay \o/ | ts -> 2019-01-25T19:39:52.636Z
logger -> de.kaufhof.ets.logging.test.Main$README$ | ts -> 2019-01-25T19:39:52.643Z | variantid -> VariantId | variantname -> VariantName
epic -> FeatureA | level -> Info | logger -> de.kaufhof.ets.logging.test.Main$README$ | msg -> yay \o/ | ts -> 2019-01-25T19:39:52.650Z
level -> Info | logger -> de.kaufhof.ets.logging.test.Main$Slf4j$ | msg -> test234 | ts -> 2019-01-25T19:39:52.707Z
level -> Info | logger -> test | msg -> Log with slf4j | ts -> 2019-01-25T19:39:52.723Z
level -> Info | logger -> de.kaufhof.ets.logging.test.Main$Configurable$ | message -> test-configurable | timestamp -> 2019-01-25T19:39:52.924Z

Configurable

In some situations it is necessary to encode into different types depending on the runtime configuration. As a developer it would be convenient to see a compact string output but during local development. However, in production it is often required to log JSON instead of string. The de.kaufhof.ets.logging.test.encoding.configurable.TupledLogConfig show cases this at test/encoding/configurable.scala. It makes use of the DefaultPairEncoders[String, Json] to encode into a tuple (String, Json). The used encoders help to make sure that attributes given to the logger do only compile if both encoders are available. Encoders are passed into a combiner which actually evaluates the values and applies the encoding. The used TupleToEitherCombiner allows to resort to only one of both alternatives at runtime. The interesting part to decide this is the takeLeft(e: LogEvent[(String, Json)]): Boolean method. Implement an appropriate expression to decide which alternative the combiner should keep.

override def combiner: EventCombiner = new TupleToEitherCombiner[String, Json] {
  // setup logic to either take left or right
  override def takeLeft(e: LogEvent[(String, Json)]): Boolean = true

  override def combiner1: LogEventCombiner[String, String] = StringLogEventCombiner
  override def combiner2: LogEventCombiner[Json, Json] = JsonLogEventCombiner
}

Module Layout

The project is organized as a maven multi module project. The idea is that the ets-logging-core doesn't deliver any dependencies. Code fragments that require any dependencies are delivered as sub modules. Any sub module requires the ets-logging-core to work properly. Not all planned sub modules are already available yet. Artificats available only during prototyping will disappear once the project is stable.

<repository-root>
├── ets-logging-apisandbox       !!! only during prototyping !!! organized with sbt
├── ets-logging-parent           parent pom project to share configs between sub modules
├── ets-logging-core             general api without dependencies
├── ets-logging-actor            akka.actor.Actor specific predefined keys and encoders
├── ets-logging-playjson         play.JsValue encoders
├── ets-logging-circejson        circe.Json encoders
├── ets-logging-catsio           cats.effect.IO appender
├── ets-logging-slf4j            org.slf4j.spi.SLF4JServiceProvider implementation to gather events from sfl4j
├── ets-logging-logstash         !!! not available yet !!! slf4j.Marker encoders
├── ets-logging-mdc              !!! not available yet !!! derive decomposer from case classes
├── ets-logging-shapeless        !!! not available yet !!! derive decomposer from case classes
└── ets-logging-usage            example usage combining all modules

ets-logging-apisandbox

This is the place to lay down the target api. Furthermore, it is used to test some api aspects. Aspects include: General API Look And Feel, Type inference, Performance, Physical Module Layout, Example Configuration. To cover all aspects, all required dependencies are available at once in a single sbt build. Aspects are covered in separate packages as single scala files. Physical modules that emerge are developed as objects within those files. Names are still very volatile.

ets-logging-slf4j

This module helps to integrate emitted log4j log events into the ets-logging framework.

Necessary steps:

  • Implement an EtsSlf4jSpiProvider
  • Setup ServiceLoader SPI binding

The simplest setup just takes a previously created LogConfig instance:

class Slf4jProvider extends EtsSlf4jSpiProvider[String, Unit] {
  override def config: DefaultLogConfig[String, Unit] = StringLogConfig
}

Override some default settings by overriding the respective methods:

class Slf4jProvider extends EtsSlf4jSpiProvider[String, Unit] {
  override def config: DefaultLogConfig[String, Unit] = StringLogConfig

  override def logMarker: EtsSlf4jSpiProvider.LogAttributeResolver[E] = {
   case m: ExtendedTestMarker => StringKeys.ExtendedTestMarker -> m
   case m: TestMarker => StringKeys.TestMarker -> m
  }

  override def levelForLogger(name: String, marker: Marker): Level = {
    if (marker.isInstanceOf[ExtendedTestMarker] && name == "myLogger") {
      Level.Debug
    } else {
      super.levelForLogger(name, marker)
    }
  }
}

To register a valid ServiceLoader SPI binding create a file called org.slf4j.spi.SLF4JServiceProvider. Put the fully-qualified class name of the class' implementation into it. Place the file on the classpath under META-INF/services. These commands illustrate the necessary steps for a maven like project residing in a directory called <project>.

mkdir -p <project>/src/main/resources/META-INF/services/
cd <project>/src/main/resources/META-INF/services/
echo "de.kaufhof.ets.logging.test.Slf4jProvider" > org.slf4j.spi.SLF4JServiceProvider

There is a runnable version of this example at test/encoding/slf4j.scala. Take de.kaufhof.ets.logging.test.Main.Slf4j as a reference to how it is being used.

About

Logging Framework optimized for use with the ELK stack.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •