Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// https://typelevel.org/sbt-typelevel/faq.html#what-is-a-base-version-anyway
ThisBuild / tlBaseVersion := "1.0" // your current series x.y
ThisBuild / tlBaseVersion := "2.0" // your current series x.y

ThisBuild / organization := "com.permutive"
ThisBuild / organizationName := "Permutive"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,9 @@ trait DynamicOdinConsoleLogger[F[_]] extends Logger[F] {
}

class DynamicOdinConsoleLoggerImpl[F[_]: Monad: Clock] private[odin] (
ref: Ref[F, (RuntimeConfig, Logger[F])],
level: Level
ref: Ref[F, (RuntimeConfig, Logger[F])]
)(make: RuntimeConfig => Logger[F])(implicit eq: Eq[RuntimeConfig])
extends DefaultLogger[F](level)
extends DefaultLogger[F](Level.Trace)
Copy link
Contributor

@janstenpickle janstenpickle Mar 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels a bit dangerous. With this being set, we will inspect the ref on every log message, but it does at least mean that the level mapping can override the default level setting.

I.e. every trace/debug message will hit the ref via the submit implementation below, rather than being filtered beforehand by this level here.

I'm wondering whether we should re-introduce minLevel which is the "absolute" minimum and cannot be lowered at runtime to prevent trace messages from hitting the ref.

with DynamicOdinConsoleLogger[F] { outer =>
protected def withLogger(f: Logger[F] => F[Unit]): F[Unit] = ref.get.flatMap {
case (_, l) => f(l)
Expand All @@ -56,32 +55,74 @@ class DynamicOdinConsoleLoggerImpl[F[_]: Monad: Clock] private[odin] (
override def submit(msg: LoggerMessage): F[Unit] = withLogger(_.log(msg))

override def withMinimalLevel(level: Level): Logger[F] =
new DynamicOdinConsoleLoggerImpl[F](ref, level)(make) {
new DynamicOdinConsoleLoggerImpl[F](ref)(make) {
override protected def withLogger(f: Logger[F] => F[Unit]): F[Unit] =
outer.withLogger(l => f(l.withMinimalLevel(level)))
}
}

object DynamicOdinConsoleLogger {
case class Config(
formatter: Formatter,
asyncTimeWindow: FiniteDuration = 1.millis,
asyncMaxBufferSize: Option[Int] = None
sealed abstract class Config private (
val formatter: Formatter,
val asyncTimeWindow: FiniteDuration,
val asyncMaxBufferSize: Option[Int]
)

case class RuntimeConfig(
minLevel: Level,
levelMapping: Map[String, Level] = Map.empty
object Config {
def apply(
formatter: Formatter,
asyncTimeWindow: FiniteDuration = 1.millis,
asyncMaxBufferSize: Option[Int] = None
): Config = new Config(formatter, asyncTimeWindow, asyncMaxBufferSize) {}
}

sealed abstract class RuntimeConfig private (
val defaultLevel: Level,
val levelMapping: Map[String, LevelConfig]
)
object RuntimeConfig {
implicit val eq: Eq[RuntimeConfig] = cats.derived.semiauto.eq
def apply(
defaultLevel: Level,
levelMapping: Map[String, LevelConfig] = Map.empty
): RuntimeConfig = new RuntimeConfig(defaultLevel, levelMapping) {}

implicit val eq: Eq[RuntimeConfig] =
Eq.by(config => (config.defaultLevel, config.levelMapping))
}

sealed trait LevelConfig

object LevelConfig {
private[odin] trait ToLevel { self: LevelConfig =>
def toLevel: Level
}

case object Trace extends LevelConfig with ToLevel {
val toLevel = Level.Trace
}
case object Debug extends LevelConfig with ToLevel {
val toLevel = Level.Debug
}
case object Info extends LevelConfig with ToLevel {
val toLevel = Level.Info
}
case object Warn extends LevelConfig with ToLevel {
val toLevel = Level.Warn
}
case object Error extends LevelConfig with ToLevel {
val toLevel = Level.Error
}
case object Unknown extends LevelConfig
case object Off extends LevelConfig

implicit val eq: Eq[LevelConfig] = cats.derived.semiauto.eq
}

def console[F[_]: Async](config: Config, initialConfig: RuntimeConfig)(
implicit eq: Eq[RuntimeConfig]
): Resource[F, DynamicOdinConsoleLogger[F]] =
create(config, initialConfig)(c =>
consoleLogger(config.formatter, c.minLevel)
consoleLogger(config.formatter, c.defaultLevel)
)

def create[F[_]: Async](
Expand All @@ -95,14 +136,17 @@ object DynamicOdinConsoleLogger {
val makeWithLevels: RuntimeConfig => Logger[F] = { config =>
val mainLogger = make(config)

if (config.levelMapping.isEmpty) mainLogger
if (config.levelMapping.isEmpty)
mainLogger.withMinimalLevel(config.defaultLevel)
else
enclosureRouting(
config.levelMapping.view
.mapValues(mainLogger.withMinimalLevel)
.toList: _*
)
.withFallback(mainLogger)
config.levelMapping.view.mapValues {
case _: LevelConfig.Off.type => Logger.noop
case _: LevelConfig.Unknown.type => mainLogger
case level: LevelConfig.ToLevel =>
mainLogger.withMinimalLevel(level.toLevel)
}.toList: _*
).withFallback(mainLogger.withMinimalLevel(config.defaultLevel))
}

for {
Expand All @@ -112,8 +156,7 @@ object DynamicOdinConsoleLogger {
)
)
underlying = new DynamicOdinConsoleLoggerImpl[F](
ref,
runtimeConfig.minLevel
ref
)(makeWithLevels)
async <- underlying.withAsync(
config.asyncTimeWindow,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,69 +16,157 @@

package com.permutive.logging.dynamic.odin

import cats.effect.unsafe.IORuntime
import cats.effect.{IO, Resource}
import com.permutive.logging.dynamic.odin.DynamicOdinConsoleLogger.{
LevelConfig,
RuntimeConfig
}
import com.permutive.logging.odin.testing.OdinRefLogger
import io.odin.{Level, LoggerMessage}
import io.odin.formatter.Formatter
import io.odin.meta.Position
import io.odin.{Level, LoggerMessage}
import munit.{CatsEffectSuite, ScalaCheckEffectSuite}
import org.scalacheck.Arbitrary
import org.scalacheck.effect.PropF

import scala.collection.immutable.Queue
import scala.concurrent.duration._

class DynamicOdinLoggerSpec extends CatsEffectSuite with ScalaCheckEffectSuite {

implicit val runtime: IORuntime = IORuntime.global
private implicit val arbPosition: Arbitrary[Position] =
Arbitrary(
Arbitrary
.arbitrary[(String, String, String, Int)]
.map((Position.apply _).tupled)
)

test("record a message") {
PropF.forAllF { (message: String) =>
val messages = runTest(_.info(message))
private val defaultConfig = RuntimeConfig(Level.Info)

messages.map(_.map(_.message.value).toList).assertEquals(List(message))
test("record only messages at the min level") {
PropF.forAllF { (debugMessage: String, infoMessage: String) =>
val messages = runTest()(logger =>
logger.debug(debugMessage) >>
logger.info(infoMessage)
)

messages
.map(_.map(_.message.value).toList)
.assertEquals(List(infoMessage))
}
}

test("update global log level") {
PropF.forAllF { (message1: String, message2: String) =>
val messages = runTest { logger =>
logger.info(message1) >> IO.sleep(10.millis) >> logger.update(
DynamicOdinConsoleLogger.RuntimeConfig(Level.Warn)
) >> logger.info(
message2
test("disable logging for a particular enclosure") {
PropF.forAllF {
(
pos1Msg: String,
pos2Msg: String,
position1: Position
) =>
val position2 =
position1.copy(enclosureName = position1.enclosureName + "2")
val messages = runTest(
RuntimeConfig(
Level.Info,
Map(
position2.enclosureName -> LevelConfig.Off
)
)
)(logger =>
logger.info(pos1Msg)(implicitly, position1) >>
logger.error(pos2Msg)(implicitly, position2)
)

messages
.map(_.map(_.message.value).toList)
.assertEquals(List(pos1Msg))
}
}

test("raises default-level config") {
PropF.forAllF { (messageBeforeChange: String, messageAfterChange: String) =>
val messages = runTest() { logger =>
logger.info(messageBeforeChange) >>
IO.sleep(10.millis) >>
logger.update(RuntimeConfig(defaultLevel = Level.Warn)) >>
logger.info(messageAfterChange)
}
messages
.map(_.map(_.message.value).toList)
.assertEquals(List(messageBeforeChange))
}
}

test("lowers default-level config") {
PropF.forAllF { (messageBeforeChange: String, messageAfterChange: String) =>
val messages = runTest() { logger =>
logger.info(messageBeforeChange) >>
IO.sleep(10.millis) >>
logger.update(RuntimeConfig(defaultLevel = Level.Debug)) >>
logger.debug(messageAfterChange)
}
messages.map(_.map(_.message.value).toList).assertEquals(List(message1))
messages
.map(_.map(_.message.value).toList)
.assertEquals(List(messageBeforeChange, messageAfterChange))
}
Comment on lines +100 to 111
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would have failed in the old implementation as the logging level of DynamicOdinConsoleLoggerImpl would have been set to what ever the original value in RuntimeConfig was. This meant that you could raise the logging level, but never lower it!

The knock on effect of this change is covered in this comment

}

test("update enclosure log level") {
PropF.forAllF { (message1: String, message2: String, message3: String) =>
val messages = runTest { logger =>
logger.info(message1) >> IO.sleep(10.millis) >> logger.update(
DynamicOdinConsoleLogger.RuntimeConfig(
Level.Info,
Map("com.permutive" -> Level.Warn)
)
) >> logger.info(
message2
) >> logger.warn(message3)
test("overrides default level for a certain package") {
PropF.forAllF { (message: String) =>
val messages = runTest(
RuntimeConfig(
defaultLevel = Level.Warn,
Map("com.permutive.logging.dynamic.odin" -> LevelConfig.Debug)
)
) { logger =>
logger.debug(message)
}
messages
.map(_.map(_.message.value).toList)
.assertEquals(List(message1, message3))
.assertEquals(List(message))
}
}

test("update enclosure log level") {
PropF.forAllNoShrinkF {
(
infoMsg1Pos1: String,
infoMsg2Pos1: String,
warnMsg1Pos1: String,
infoMsg2Pos2: String,
position1: Position,
position2: Position
) =>
val messages = runTest() { logger =>
val positionWhichChangesLevel =
position1.copy(enclosureName = position1.enclosureName + "changes")
logger.info(infoMsg1Pos1)(implicitly, positionWhichChangesLevel) >>
IO.sleep(10.millis) >>
logger.update(
RuntimeConfig(
Level.Info,
Map(positionWhichChangesLevel.enclosureName -> LevelConfig.Warn)
)
) >>
logger.info(infoMsg2Pos1)(implicitly, positionWhichChangesLevel) >>
logger.warn(warnMsg1Pos1)(implicitly, positionWhichChangesLevel) >>
logger.info(infoMsg2Pos2)(implicitly, position2)
}
messages
.map(_.map(_.message.value).toList)
.assertEquals(List(infoMsg1Pos1, warnMsg1Pos1, infoMsg2Pos2))
}
}

def runTest(
private def runTest(initialConfig: RuntimeConfig = defaultConfig)(
useLogger: DynamicOdinConsoleLogger[IO] => IO[Unit]
): IO[Queue[LoggerMessage]] = (for {
testLogger <- Resource.eval(OdinRefLogger.create[IO]())
dynamic <- DynamicOdinConsoleLogger.create[IO](
DynamicOdinConsoleLogger
.Config(formatter = Formatter.default, asyncTimeWindow = 0.nanos),
DynamicOdinConsoleLogger.RuntimeConfig(Level.Info)
)(config => testLogger.withMinimalLevel(config.minLevel))
initialConfig
)(config => testLogger.withMinimalLevel(config.defaultLevel))
_ <- Resource.eval(useLogger(dynamic))
} yield testLogger)
.use { testLogger =>
Expand Down