From d1e4e6a568c22baf6dbebba6bb1564b1a0c3b8c3 Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Fri, 12 Dec 2025 16:55:22 -0500 Subject: [PATCH] introduce DelegatingLogger This idea was introduced in log4cats-natchez, and it was suggested it be moved upstream --- build.sbt | 5 +- .../typelevel/log4cats/DelegatingLogger.scala | 90 +++++++++++++ .../testing/StructuredTestingLogger.scala | 18 +++ .../log4cats/DelegatingLoggerSuite.scala | 125 ++++++++++++++++++ 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 core/shared/src/main/scala/org/typelevel/log4cats/DelegatingLogger.scala create mode 100644 testing/shared/src/test/scala/org/typelevel/log4cats/DelegatingLoggerSuite.scala diff --git a/build.sbt b/build.sbt index 84712347..03513317 100644 --- a/build.sbt +++ b/build.sbt @@ -62,8 +62,9 @@ lazy val testing = crossProject(JSPlatform, JVMPlatform, NativePlatform) .settings( name := "log4cats-testing", libraryDependencies ++= Seq( - "org.typelevel" %%% "cats-effect" % catsEffectV, - "ch.qos.logback" % "logback-classic" % logbackClassicV % Test + "org.typelevel" %%% "cats-effect" % catsEffectV, + "org.typelevel" %%% "scalacheck-effect-munit" % "2.1.0-RC1" % Test, + "ch.qos.logback" % "logback-classic" % logbackClassicV % Test ) ) .nativeSettings(commonNativeSettings) diff --git a/core/shared/src/main/scala/org/typelevel/log4cats/DelegatingLogger.scala b/core/shared/src/main/scala/org/typelevel/log4cats/DelegatingLogger.scala new file mode 100644 index 00000000..f87df9f8 --- /dev/null +++ b/core/shared/src/main/scala/org/typelevel/log4cats/DelegatingLogger.scala @@ -0,0 +1,90 @@ +/* + * Copyright 2018 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +package org.typelevel.log4cats + +import cats.* +import cats.syntax.all.* + +/** + * A logger that delegates all logging operations to another logger that is wrapped in an effect + * F[_]. + * + * This is useful when you need to modify the underlying logger for each log operation. A common use + * case is adding the current tracing context. For example, if your application uses Natchez, you + * might have something like: + * {{{ + * def tracedLogger[F[_]](logger: SelfAwareStructuredLogger[F], trace: Trace[F]): SelfAwareStructuredLogger[F] = + * new DelegatingLogger( + * (trace.traceId, trace.spanId).mapN { (traceId, spanId) => + * logger.addContext(Map( + * "trace.id" -> traceId.toString, + * "span.id" -> spanId.toString + * )) + * } + * ) + * }}} + * + * @param delegate + * The effect containing the logger to delegate to. This effect is evaluated for each logging + * operation. + * @tparam F + * The effect type, which must have a FlatMap instance + */ +class DelegatingLogger[F[_]: FlatMap](delegate: F[SelfAwareStructuredLogger[F]]) + extends SelfAwareStructuredLogger[F] { + override def isTraceEnabled: F[Boolean] = delegate.flatMap(_.isTraceEnabled) + override def isDebugEnabled: F[Boolean] = delegate.flatMap(_.isDebugEnabled) + override def isInfoEnabled: F[Boolean] = delegate.flatMap(_.isInfoEnabled) + override def isWarnEnabled: F[Boolean] = delegate.flatMap(_.isWarnEnabled) + override def isErrorEnabled: F[Boolean] = delegate.flatMap(_.isErrorEnabled) + + override def trace(ctx: Map[String, String])(msg: => String): F[Unit] = + delegate.flatMap(_.trace(ctx)(msg)) + override def trace(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + delegate.flatMap(_.trace(ctx, t)(msg)) + override def debug(ctx: Map[String, String])(msg: => String): F[Unit] = + delegate.flatMap(_.debug(ctx)(msg)) + override def debug(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + delegate.flatMap(_.debug(ctx, t)(msg)) + override def info(ctx: Map[String, String])(msg: => String): F[Unit] = + delegate.flatMap(_.info(ctx)(msg)) + override def info(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + delegate.flatMap(_.info(ctx, t)(msg)) + override def warn(ctx: Map[String, String])(msg: => String): F[Unit] = + delegate.flatMap(_.warn(ctx)(msg)) + override def warn(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + delegate.flatMap(_.warn(ctx, t)(msg)) + override def error(ctx: Map[String, String])(msg: => String): F[Unit] = + delegate.flatMap(_.error(ctx)(msg)) + override def error(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + delegate.flatMap(_.error(ctx, t)(msg)) + override def error(t: Throwable)(message: => String): F[Unit] = + delegate.flatMap(_.error(t)(message)) + override def warn(t: Throwable)(message: => String): F[Unit] = + delegate.flatMap(_.warn(t)(message)) + override def info(t: Throwable)(message: => String): F[Unit] = + delegate.flatMap(_.info(t)(message)) + override def debug(t: Throwable)(message: => String): F[Unit] = + delegate.flatMap(_.debug(t)(message)) + override def trace(t: Throwable)(message: => String): F[Unit] = + delegate.flatMap(_.trace(t)(message)) + override def error(message: => String): F[Unit] = delegate.flatMap(_.error(message)) + override def warn(message: => String): F[Unit] = delegate.flatMap(_.warn(message)) + override def info(message: => String): F[Unit] = delegate.flatMap(_.info(message)) + override def debug(message: => String): F[Unit] = delegate.flatMap(_.debug(message)) + override def trace(message: => String): F[Unit] = delegate.flatMap(_.trace(message)) +} diff --git a/testing/shared/src/main/scala/org/typelevel/log4cats/testing/StructuredTestingLogger.scala b/testing/shared/src/main/scala/org/typelevel/log4cats/testing/StructuredTestingLogger.scala index 640a67f5..6d38472a 100644 --- a/testing/shared/src/main/scala/org/typelevel/log4cats/testing/StructuredTestingLogger.scala +++ b/testing/shared/src/main/scala/org/typelevel/log4cats/testing/StructuredTestingLogger.scala @@ -35,6 +35,24 @@ object StructuredTestingLogger { def ctx: Map[String, String] def message: String def throwOpt: Option[Throwable] + + def mapCtx(f: Map[String, String] => Map[String, String]): LogMessage = + this match { + case TRACE(m, t, c) => TRACE(m, t, f(c)) + case DEBUG(m, t, c) => DEBUG(m, t, f(c)) + case INFO(m, t, c) => INFO(m, t, f(c)) + case WARN(m, t, c) => WARN(m, t, f(c)) + case ERROR(m, t, c) => ERROR(m, t, f(c)) + } + + def modifyString(f: String => String): LogMessage = + this match { + case TRACE(m, t, c) => TRACE(f(m), t, c) + case DEBUG(m, t, c) => DEBUG(f(m), t, c) + case INFO(m, t, c) => INFO(f(m), t, c) + case WARN(m, t, c) => WARN(f(m), t, c) + case ERROR(m, t, c) => ERROR(f(m), t, c) + } } final case class TRACE( diff --git a/testing/shared/src/test/scala/org/typelevel/log4cats/DelegatingLoggerSuite.scala b/testing/shared/src/test/scala/org/typelevel/log4cats/DelegatingLoggerSuite.scala new file mode 100644 index 00000000..dcea08c4 --- /dev/null +++ b/testing/shared/src/test/scala/org/typelevel/log4cats/DelegatingLoggerSuite.scala @@ -0,0 +1,125 @@ +/* + * Copyright 2018 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +package org.typelevel.log4cats + +import cats.syntax.all.* +import cats.effect.* +import munit.{CatsEffectSuite, ScalaCheckEffectSuite} +import org.scalacheck.* +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.effect.PropF +import org.typelevel.log4cats.testing.StructuredTestingLogger +import org.typelevel.log4cats.testing.StructuredTestingLogger.{ + DEBUG, + ERROR, + INFO, + LogMessage, + TRACE, + WARN +} + +class DelegatingLoggerSuite extends CatsEffectSuite with ScalaCheckEffectSuite { + private val genLogMessage: Gen[LogMessage] = + for { + msg <- arbitrary[String] + ex <- arbitrary[Option[Throwable]] + ctx <- arbitrary[Map[String, String]] + constructor <- Gen.oneOf[(String, Option[Throwable], Map[String, String]) => LogMessage]( + TRACE.apply _, + DEBUG.apply _, + INFO.apply _, + WARN.apply _, + ERROR.apply _ + ) + } yield constructor(msg, ex, ctx) + + private implicit val arbLogMessage: Arbitrary[LogMessage] = Arbitrary(genLogMessage) + + test("messages should be given to the underlying logger") { + PropF.forAllF { + ( + expectedCtx: Map[String, String], + expectedStringModifier: String => String, + events: Vector[LogMessage] + ) => + for { + underlying <- StructuredTestingLogger.ref[IO]() + + logger = new DelegatingLogger[IO]( + underlying + .addContext(expectedCtx) + .withModifiedString(expectedStringModifier) + .pure[IO] + ) + + _ <- events.traverse { + case TRACE(message, Some(ex), ctx) if ctx.isEmpty => logger.trace(ex)(message) + case TRACE(message, None, ctx) if ctx.isEmpty => logger.trace(message) + case TRACE(message, Some(ex), ctx) => logger.trace(ctx, ex)(message) + case TRACE(message, None, ctx) => logger.trace(ctx)(message) + + case DEBUG(message, Some(ex), ctx) if ctx.isEmpty => logger.debug(ex)(message) + case DEBUG(message, None, ctx) if ctx.isEmpty => logger.debug(message) + case DEBUG(message, Some(ex), ctx) => logger.debug(ctx, ex)(message) + case DEBUG(message, None, ctx) => logger.debug(ctx)(message) + + case INFO(message, Some(ex), ctx) if ctx.isEmpty => logger.info(ex)(message) + case INFO(message, None, ctx) if ctx.isEmpty => logger.info(message) + case INFO(message, Some(ex), ctx) => logger.info(ctx, ex)(message) + case INFO(message, None, ctx) => logger.info(ctx)(message) + + case WARN(message, Some(ex), ctx) if ctx.isEmpty => logger.warn(ex)(message) + case WARN(message, None, ctx) if ctx.isEmpty => logger.warn(message) + case WARN(message, Some(ex), ctx) => logger.warn(ctx, ex)(message) + case WARN(message, None, ctx) => logger.warn(ctx)(message) + + case ERROR(message, Some(ex), ctx) if ctx.isEmpty => logger.error(ex)(message) + case ERROR(message, None, ctx) if ctx.isEmpty => logger.error(message) + case ERROR(message, Some(ex), ctx) => logger.error(ctx, ex)(message) + case ERROR(message, None, ctx) => logger.error(ctx)(message) + } + + loggedEvents <- underlying.logged + expectedEvents = events.map( + _.mapCtx(expectedCtx ++ _).modifyString(expectedStringModifier) + ) + } yield assertEquals(loggedEvents, expectedEvents) + } + } + + test("transformation effect is applied for each subsequent log event") { + val indexes = (0 until 10).toVector + for { + countdown <- Ref[IO].of(indexes) + expectedLogEvents = indexes.map(i => INFO("log event", None, Map("index" -> i.toString))) + + underlying <- StructuredTestingLogger.ref[IO]() + + transform = + countdown.modify { + case i +: tail => (tail, underlying.addContext(Map("index" -> i.toString))) + case _ => throw new IllegalStateException("Countdown is empty") + } + + logger = new DelegatingLogger[IO](transform) + + _ <- indexes.traverse_(_ => logger.info("log event")) + + loggedEvents <- underlying.logged + } yield assertEquals(loggedEvents, expectedLogEvents) + } +}