From cb2bb5868a5fe54a2e83f1702748e54c0bd1a771 Mon Sep 17 00:00:00 2001 From: Marissa Date: Tue, 8 Apr 2025 23:02:26 -0400 Subject: [PATCH 1/7] Add logger with `Local` semantics Add `LocalLogContext` as the core abstraction for storing context locally, as well as for querying context from arbitrary `cats.mtl.Ask` instances (for example, to automatically include the current trace and span IDs in a log event's context). Add `LocalLogger` and `LocalLoggerFactory`. --- build.sbt | 4 +- .../typelevel/log4cats/LocalLogContext.scala | 134 +++++++++ .../org/typelevel/log4cats/LocalLogger.scala | 274 ++++++++++++++++++ .../log4cats/LocalLoggerFactory.scala | 88 ++++++ .../log4cats/LocalLogContextTest.scala | 101 +++++++ .../typelevel/log4cats/LocalLoggerTest.scala | 176 +++++++++++ .../org/typelevel/log4cats/TestLogger.scala | 179 ++++++++++++ 7 files changed, 955 insertions(+), 1 deletion(-) create mode 100644 core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala create mode 100644 core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala create mode 100644 core/shared/src/main/scala/org/typelevel/log4cats/LocalLoggerFactory.scala create mode 100644 core/shared/src/test/scala/org/typelevel/log4cats/LocalLogContextTest.scala create mode 100644 core/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala create mode 100644 core/shared/src/test/scala/org/typelevel/log4cats/TestLogger.scala diff --git a/build.sbt b/build.sbt index 73ab01e9..dba952bc 100644 --- a/build.sbt +++ b/build.sbt @@ -28,6 +28,7 @@ ThisBuild / tlVersionIntroduced := Map("3" -> "2.1.1") val catsV = "2.13.0" val catsEffectV = "3.7.0-RC1" +val catsMtlV = "1.6.0" val slf4jV = "1.7.36" val munitCatsEffectV = "2.2.0-RC1" val logbackClassicV = "1.2.13" @@ -47,7 +48,8 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) name := "log4cats-core", libraryDependencies ++= Seq( "org.typelevel" %%% "cats-core" % catsV, - "org.typelevel" %%% "cats-effect-std" % catsEffectV + "org.typelevel" %%% "cats-effect-std" % catsEffectV, + "org.typelevel" %%% "cats-mtl" % catsMtlV ), libraryDependencies ++= { if (tlIsScala3.value) Seq.empty diff --git a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala new file mode 100644 index 00000000..2d42e0d1 --- /dev/null +++ b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala @@ -0,0 +1,134 @@ +/* + * 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.mtl.{Ask, LiftKind, Local} +import cats.syntax.functor.* +import cats.syntax.traverse.* +import cats.{Applicative, Show} + +import scala.collection.immutable.ArraySeq + +/** + * Log context stored in a [[cats.mtl.Local `Local`]], as well as potentially additional log context + * provided by [[cats.mtl.Ask `Ask`s]]. + */ +sealed trait LocalLogContext[F[_]] { + + /** + * @return + * the current log context stored [[cats.mtl.Local locally]], as well as the context from any + * provided [[cats.mtl.Ask `Ask`]]s + */ + private[log4cats] def currentLogContext: F[Map[String, String]] + + /** + * @return + * the given effect modified to have the provided context stored [[cats.mtl.Local locally]] + */ + private[log4cats] def withAddedContext[A](ctx: Map[String, String])(fa: F[A]): F[A] + + /** + * @return + * the given effect modified to have the provided context stored [[cats.mtl.Local locally]] + */ + private[log4cats] final def withAddedContext[A](ctx: (String, Show.Shown)*)(fa: F[A]): F[A] = + withAddedContext { + ctx.view.map { case (k, v) => k -> v.toString }.toMap + }(fa) + + /** + * Modifies this [[cats.mtl.Local local]] log context to include the context provided by the given + * [[cats.mtl.Ask `Ask`]] with higher priority than all of its current context; that is, if both + * the `Ask` and this local log context provide values for some key, the value from the `Ask` will + * be used. The context is asked for at every logging operation. + */ + def withHighPriorityAskedContext(ask: Ask[F, Map[String, String]]): LocalLogContext[F] + + /** + * Modifies this [[cats.mtl.Local local]] log context to include the context provided by the given + * [[cats.mtl.Ask `Ask`]] with lower priority than all of its current context; that is, if both + * the `Ask` and this local log context provide values for some key, the value from this local log + * context will be used. The context is asked for at every logging operation. + */ + def withLowPriorityAskedContext(ask: Ask[F, Map[String, String]]): LocalLogContext[F] + + /** Lifts this [[cats.mtl.Local local]] log context from `F` to `G`. */ + def liftTo[G[_]](implicit lift: LiftKind[F, G]): LocalLogContext[G] +} + +object LocalLogContext { + private[this] type AskContext[F[_]] = Ask[F, Map[String, String]] + + private[this] final class MultiAskContext[F[_]] private[MultiAskContext] ( + asks: Seq[AskContext[F]] /* never empty */ + ) extends AskContext[F] { + implicit def applicative: Applicative[F] = asks.head.applicative + def ask[E2 >: Map[String, String]]: F[E2] = + asks + .traverse(_.ask[Map[String, String]]) + .map(_.reduceLeft(_ ++ _)) + def prependLowPriority(ask: AskContext[F]): MultiAskContext[F] = + new MultiAskContext(ask +: asks) + def appendHighPriority(ask: AskContext[F]): MultiAskContext[F] = + new MultiAskContext(asks :+ ask) + } + + private[this] object MultiAskContext { + def apply[F[_]](ask: AskContext[F]): MultiAskContext[F] = + ask match { + case multi: MultiAskContext[F] => multi + case other => new MultiAskContext(ArraySeq(other)) + } + } + + private[this] final class Impl[F[_]]( + localCtx: Local[F, Map[String, String]], + askCtx: AskContext[F] + ) extends LocalLogContext[F] { + private[log4cats] def currentLogContext: F[Map[String, String]] = + askCtx.ask[Map[String, String]] + private[log4cats] def withAddedContext[A](ctx: Map[String, String])(fa: F[A]): F[A] = + localCtx.local(fa)(_ ++ ctx) + + def withHighPriorityAskedContext(ask: Ask[F, Map[String, String]]): LocalLogContext[F] = + new Impl( + localCtx, + MultiAskContext(askCtx).appendHighPriority(ask) + ) + + def withLowPriorityAskedContext(ask: Ask[F, Map[String, String]]): LocalLogContext[F] = + new Impl( + localCtx, + MultiAskContext(askCtx).prependLowPriority(ask) + ) + + def liftTo[G[_]](implicit lift: LiftKind[F, G]): LocalLogContext[G] = { + val localF = localCtx + val askF = askCtx + val localG = localF.liftTo[G] + val askG = + if (askF eq localF) localG + else askF.liftTo[G] + new Impl(localG, askG) + } + } + + /** @return a `LocalLogContext` backed by the given implicit [[cats.mtl.Local `Local`]] */ + def fromLocal[F[_]](implicit localCtx: Local[F, Map[String, String]]): LocalLogContext[F] = + new Impl(localCtx, localCtx) +} diff --git a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala new file mode 100644 index 00000000..01a72f8a --- /dev/null +++ b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala @@ -0,0 +1,274 @@ +/* + * 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.mtl.{LiftKind, Local} +import cats.syntax.flatMap.* +import cats.{~>, Monad, Show} + +/** + * A logger with [[cats.mtl.Local `Local`]] semantics. + * + * @see + * [[withAddedContext]] + */ +sealed trait LocalLogger[F[_]] extends SelfAwareLogger[F] { + + /** + * Modifies the given effect to have the provided context stored [[cats.mtl.Local locally]]. + * + * Context added using this method is available to all loggers created by this logger's + * [[LocalLoggerFactory parent factory]]. + */ + def withAddedContext[A](ctx: Map[String, String])(fa: F[A]): F[A] + + /** + * Modifies the given effect to have the provided context stored [[cats.mtl.Local locally]]. + * + * Context added using this method is available to all loggers created by this logger's + * [[LocalLoggerFactory parent factory]]. + */ + def withAddedContext[A](ctx: (String, Show.Shown)*)(fa: F[A]): F[A] + + def error(ctx: Map[String, String])(msg: => String): F[Unit] + def error(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] + def warn(ctx: Map[String, String])(msg: => String): F[Unit] + def warn(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] + def info(ctx: Map[String, String])(msg: => String): F[Unit] + def info(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] + def debug(ctx: Map[String, String])(msg: => String): F[Unit] + def debug(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] + def trace(ctx: Map[String, String])(msg: => String): F[Unit] + def trace(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] + + @deprecated("use `liftTo` instead", since = "log4cats 2.8.0") + override def mapK[G[_]](fk: F ~> G): SelfAwareLogger[G] = super.mapK(fk) + + /** Lifts this logger's context from `F` to `G`. */ + def liftTo[G[_]](implicit lift: LiftKind[F, G], G: Monad[G]): LocalLogger[G] + + protected[this] def withModifiedStringImpl(f: String => String): LocalLogger[F] + + override def withModifiedString(f: String => String): LocalLogger[F] = + withModifiedStringImpl(f) + + /** + * A view of this logger as a [[`StructuredLogger`]], to support gradual migration away from + * `StructuredLogger`. Log context added using this `LocalLogger` or its + * [[LocalLoggerFactory parent factory]] will be included in log messages created by + * `StructuredLogger`s returned by this method, regardless of the scope in which this method was + * called. + */ + @deprecated( + "`StructuredLogger` is cumbersome and lacks `cats.mtl.Local` semantics", + since = "log4cats 2.8.0" + ) + def asStructuredLogger: SelfAwareStructuredLogger[F] +} + +object LocalLogger { + private[this] final class Impl[F[_]]( + localLogContext: LocalLogContext[F], + underlying: SelfAwareStructuredLogger[F] + )(implicit F: Monad[F]) + extends LocalLogger[F] + with SelfAwareStructuredLogger[F] { + def withAddedContext[A](ctx: Map[String, String])(fa: F[A]): F[A] = + localLogContext.withAddedContext(ctx)(fa) + def withAddedContext[A](ctx: (String, Show.Shown)*)(fa: F[A]): F[A] = + localLogContext.withAddedContext(ctx*)(fa) + + override def addContext(ctx: Map[String, String]): Impl[F] = + new Impl(localLogContext, underlying.addContext(ctx)) + override def addContext(pairs: (String, Show.Shown)*): Impl[F] = + new Impl(localLogContext, underlying.addContext(pairs*)) + + def isErrorEnabled: F[Boolean] = underlying.isErrorEnabled + def isWarnEnabled: F[Boolean] = underlying.isWarnEnabled + def isInfoEnabled: F[Boolean] = underlying.isInfoEnabled + def isDebugEnabled: F[Boolean] = underlying.isDebugEnabled + def isTraceEnabled: F[Boolean] = underlying.isTraceEnabled + + @deprecated("use `liftTo` instead", since = "log4cats 2.8.0") + override def mapK[G[_]](fk: F ~> G): SelfAwareStructuredLogger[G] = + super.mapK(fk) + def liftTo[G[_]](implicit lift: LiftKind[F, G], G: Monad[G]): LocalLogger[G] = + new Impl(localLogContext.liftTo[G], underlying.mapK(lift)) + override def withModifiedStringImpl(f: String => String): Impl[F] = + new Impl(localLogContext, underlying.withModifiedString(f)) + override def withModifiedString(f: String => String): Impl[F] = + withModifiedStringImpl(f) + + @deprecated( + "`StructuredLogger` is cumbersome and lacks `cats.mtl.Local` semantics", + since = "log4cats 2.8.0" + ) + def asStructuredLogger: SelfAwareStructuredLogger[F] = this + + def error(message: => String): F[Unit] = + F.ifM(underlying.isErrorEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.error(_)(message)), + F.unit + ) + def error(t: Throwable)(message: => String): F[Unit] = + F.ifM(underlying.isErrorEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.error(_, t)(message)), + F.unit + ) + def error(ctx: Map[String, String])(msg: => String): F[Unit] = + F.ifM(underlying.isErrorEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.error(localCtx ++ ctx)(msg)), + F.unit + ) + def error(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + F.ifM(underlying.isErrorEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.error(localCtx ++ ctx, t)(msg)), + F.unit + ) + + def warn(message: => String): F[Unit] = + F.ifM(underlying.isWarnEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.warn(_)(message)), + F.unit + ) + def warn(t: Throwable)(message: => String): F[Unit] = + F.ifM(underlying.isWarnEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.warn(_, t)(message)), + F.unit + ) + def warn(ctx: Map[String, String])(msg: => String): F[Unit] = + F.ifM(underlying.isWarnEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.warn(localCtx ++ ctx)(msg)), + F.unit + ) + def warn(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + F.ifM(underlying.isWarnEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.warn(localCtx ++ ctx, t)(msg)), + F.unit + ) + + def info(message: => String): F[Unit] = + F.ifM(underlying.isInfoEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.info(_)(message)), + F.unit + ) + def info(t: Throwable)(message: => String): F[Unit] = + F.ifM(underlying.isInfoEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.info(_, t)(message)), + F.unit + ) + def info(ctx: Map[String, String])(msg: => String): F[Unit] = + F.ifM(underlying.isInfoEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.info(localCtx ++ ctx)(msg)), + F.unit + ) + def info(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + F.ifM(underlying.isInfoEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.info(localCtx ++ ctx, t)(msg)), + F.unit + ) + + def debug(message: => String): F[Unit] = + F.ifM(underlying.isDebugEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.debug(_)(message)), + F.unit + ) + def debug(t: Throwable)(message: => String): F[Unit] = + F.ifM(underlying.isDebugEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.debug(_, t)(message)), + F.unit + ) + def debug(ctx: Map[String, String])(msg: => String): F[Unit] = + F.ifM(underlying.isDebugEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.debug(localCtx ++ ctx)(msg)), + F.unit + ) + def debug(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + F.ifM(underlying.isDebugEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.debug(localCtx ++ ctx, t)(msg)), + F.unit + ) + + def trace(message: => String): F[Unit] = + F.ifM(underlying.isTraceEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.trace(_)(message)), + F.unit + ) + def trace(t: Throwable)(message: => String): F[Unit] = + F.ifM(underlying.isTraceEnabled)( + localLogContext.currentLogContext + .flatMap(underlying.trace(_, t)(message)), + F.unit + ) + def trace(ctx: Map[String, String])(msg: => String): F[Unit] = + F.ifM(underlying.isTraceEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.trace(localCtx ++ ctx)(msg)), + F.unit + ) + def trace(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + F.ifM(underlying.isTraceEnabled)( + localLogContext.currentLogContext + .flatMap(localCtx => underlying.trace(localCtx ++ ctx, t)(msg)), + F.unit + ) + } + + /** + * This method should only be used when a [[`LoggerFactory`]] is not available; when possible, + * create a [[`LocalLoggerFactory`]] and use that to create `LocalLogger`s. + * + * @return + * a [[cats.mtl.Local local]] logger backed by the given [[`LocalLogContext`]] and + * [[`LoggerFactory`]] + */ + def apply[F[_]: Monad]( + localLogContext: LocalLogContext[F], + underlying: SelfAwareStructuredLogger[F] + ): LocalLogger[F] = + new Impl(localLogContext, underlying) + + /** + * This method should only be used when a [[`LoggerFactory`]] is not available; when possible, + * create a [[`LocalLoggerFactory`]] and use that to create `LocalLogger`s. + * + * @return + * a local logger backed by the given [[`SelfAwareStructuredLogger`]] and implicit + * [[cats.mtl.Local `Local`]] + */ + def fromLocal[F[_]: Monad]( + underlying: SelfAwareStructuredLogger[F] + )(implicit localCtx: Local[F, Map[String, String]]): LocalLogger[F] = + apply(LocalLogContext.fromLocal, underlying) +} diff --git a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLoggerFactory.scala b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLoggerFactory.scala new file mode 100644 index 00000000..f4f44e26 --- /dev/null +++ b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLoggerFactory.scala @@ -0,0 +1,88 @@ +/* + * 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.mtl.{LiftKind, Local} +import cats.syntax.functor.* +import cats.{Functor, Monad, Show} + +/** A factory for [[LocalLogger loggers]] with [[cats.mtl.Local `Local`]] semantics. */ +sealed trait LocalLoggerFactory[F[_]] extends LoggerFactoryGen[F] { + final type LoggerType = LocalLogger[F] + + /** + * Modifies the given effect to have the provided context stored [[cats.mtl.Local locally]]. + * + * Context added using this method is available to all loggers created by this factory. + */ + def withAddedContext[A](ctx: Map[String, String])(fa: F[A]): F[A] + + /** + * Modifies the given effect to have the provided context stored [[cats.mtl.Local locally]]. + * + * Context added using this method is available to all loggers created by this factory. + */ + def withAddedContext[A](ctx: (String, Show.Shown)*)(fa: F[A]): F[A] + + /** Lifts this factory's context from `F` to `G`. */ + def liftTo[G[_]](implicit lift: LiftKind[F, G], G: Monad[G]): LocalLoggerFactory[G] + + def withModifiedString(f: String => String)(implicit F: Functor[F]): LocalLoggerFactory[F] +} + +object LocalLoggerFactory { + private[this] final class Impl[F[_]: Monad]( + localLogContext: LocalLogContext[F], + underlying: LoggerFactory[F] + ) extends LocalLoggerFactory[F] { + def withAddedContext[A](ctx: Map[String, String])(fa: F[A]): F[A] = + localLogContext.withAddedContext(ctx)(fa) + def withAddedContext[A](ctx: (String, Show.Shown)*)(fa: F[A]): F[A] = + localLogContext.withAddedContext(ctx*)(fa) + + def liftTo[G[_]](implicit lift: LiftKind[F, G], G: Monad[G]): LocalLoggerFactory[G] = + new Impl(localLogContext.liftTo[G], underlying.mapK(lift)) + def withModifiedString(f: String => String)(implicit F: Functor[F]): LocalLoggerFactory[F] = + new Impl(localLogContext, underlying.withModifiedString(f)) + + def getLoggerFromName(name: String): LocalLogger[F] = + LocalLogger(localLogContext, underlying.getLoggerFromName(name)) + def fromName(name: String): F[LocalLogger[F]] = + underlying.fromName(name).map(LocalLogger(localLogContext, _)) + } + + /** + * @return + * a factory for [[cats.mtl.Local local]] loggers backed by the given [[`LocalLogContext`]] and + * [[`LoggerFactory`]] + */ + def apply[F[_]: Monad]( + localLogContext: LocalLogContext[F], + underlying: LoggerFactory[F] + ): LocalLoggerFactory[F] = + new Impl(localLogContext, underlying) + + /** + * @return + * a factory for local loggers backed by the given [[`LoggerFactory`]] and implicit + * [[cats.mtl.Local `Local`]] + */ + def fromLocal[F[_]: Monad]( + underlying: LoggerFactory[F] + )(implicit localCtx: Local[F, Map[String, String]]): LocalLoggerFactory[F] = + apply(LocalLogContext.fromLocal, underlying) +} diff --git a/core/shared/src/test/scala/org/typelevel/log4cats/LocalLogContextTest.scala b/core/shared/src/test/scala/org/typelevel/log4cats/LocalLogContextTest.scala new file mode 100644 index 00000000..74a3c1f6 --- /dev/null +++ b/core/shared/src/test/scala/org/typelevel/log4cats/LocalLogContextTest.scala @@ -0,0 +1,101 @@ +/* + * 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.effect.IO +import cats.mtl.Ask +import munit.CatsEffectSuite + +class LocalLogContextTest extends CatsEffectSuite { + private[this] val baseLLC: IO[LocalLogContext[IO]] = + IO.local(Map("base" -> "init")) + .map(implicit iol => LocalLogContext.fromLocal) + + private[this] def ctxTest(name: String)(body: LocalLogContext[IO] => IO[?]): Unit = + test(name)(baseLLC.flatMap(body)) + + private[this] def ask(name: String): Ask[IO, Map[String, String]] = + Ask.const(Map("shared" -> name, name -> "1")) + + private[this] val a = ask("a") + private[this] val b = ask("b") + + ctxTest("retains value in backing Local") { llc => + for (ctx <- llc.currentLogContext) + yield assertEquals(ctx, Map("base" -> "init")) + } + + ctxTest("high priority ask overrides base") { base => + val llc = base.withHighPriorityAskedContext(a) + for { + ctx <- llc.withAddedContext("base" -> "new", "shared" -> "base") { + llc.currentLogContext + } + } yield assertEquals(ctx, Map("base" -> "new", "shared" -> "a", "a" -> "1")) + } + + ctxTest("base overrides low priority ask") { base => + val llc = base.withLowPriorityAskedContext(a) + for { + ctx <- llc.withAddedContext("base" -> "new", "shared" -> "base") { + llc.currentLogContext + } + } yield assertEquals(ctx, Map("base" -> "new", "shared" -> "base", "a" -> "1")) + } + + ctxTest("high priority ask overrides base and low priority ask") { base => + val llc = base + .withLowPriorityAskedContext(a) + .withHighPriorityAskedContext(b) + for { + ctx <- llc.withAddedContext("base" -> "new", "shared" -> "base") { + llc.currentLogContext + } + } yield assertEquals( + ctx, + Map("base" -> "new", "shared" -> "b", "a" -> "1", "b" -> "1") + ) + } + + ctxTest("second high priority ask overrides first high priority ask") { base => + val llc = base + .withHighPriorityAskedContext(a) + .withHighPriorityAskedContext(b) + for { + ctx <- llc.withAddedContext("base" -> "new", "shared" -> "base") { + llc.currentLogContext + } + } yield assertEquals( + ctx, + Map("base" -> "new", "shared" -> "b", "a" -> "1", "b" -> "1") + ) + } + + ctxTest("first low priority ask overrides second low priority ask") { base => + val llc = base + .withLowPriorityAskedContext(a) + .withLowPriorityAskedContext(b) + for { + ctx <- llc.withAddedContext(Map("base" -> "new")) { + llc.currentLogContext + } + } yield assertEquals( + ctx, + Map("base" -> "new", "shared" -> "a", "a" -> "1", "b" -> "1") + ) + } +} diff --git a/core/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala b/core/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala new file mode 100644 index 00000000..1c22310d --- /dev/null +++ b/core/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala @@ -0,0 +1,176 @@ +/* + * 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.effect.IO +import cats.mtl.Local +import munit.CatsEffectSuite +import org.typelevel.log4cats.extras.LogLevel + +import scala.annotation.nowarn + +class LocalLoggerTest extends CatsEffectSuite { + private[this] def localTest( + name: String + )(body: Local[IO, Map[String, String]] => IO[?]): Unit = + test(name) { + IO.local(Map.empty[String, String]).flatMap(body) + } + + private[this] def factoryTest( + name: String + )(body: (LocalLoggerFactory[IO], TestLogger.Output[IO]) => IO[?]): Unit = + localTest(name) { implicit local => + TestLogger.Factory().flatMap { factory => + body(LocalLoggerFactory.fromLocal(factory), factory.output) + } + } + + private[this] def loggerTest( + name: String + )(body: (LocalLogger[IO], TestLogger[IO]) => IO[?]): Unit = + localTest(name) { implicit local => + TestLogger[IO]("test").flatMap { logger => + body(LocalLogger.fromLocal(logger), logger) + } + } + + factoryTest("LocalLoggerFactory stores context locally") { (factory, output) => + factory.withAddedContext(Map("foo" -> "1")) { + for { + logger <- factory.fromName("test") + _ <- logger.info("bar") + entries <- output.loggedEntries + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + TestLogger.Entry( + loggerName = "test", + level = LogLevel.Info, + message = "bar", + exception = None, + context = Map("foo" -> "1") + ) + ) + } + } + } + + factoryTest("context stored after creation of logger is visible to logger") { (factory, output) => + for { + logger <- factory.fromName("test") + _ <- factory.withAddedContext(Map("foo" -> "1")) { + for { + _ <- logger.error("bar") + entries <- output.loggedEntries + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + TestLogger.Entry( + loggerName = "test", + level = LogLevel.Error, + message = "bar", + exception = None, + context = Map("foo" -> "1") + ) + ) + } + } + } yield () + } + + factoryTest("log site context overrides local context") { (factory, output) => + factory.withAddedContext("shared" -> "local", "foo" -> "1") { + for { + logger <- factory.fromName("test") + _ <- logger.warn(Map("shared" -> "log site", "bar" -> "1"))("baz") + entries <- output.loggedEntries + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + TestLogger.Entry( + loggerName = "test", + level = LogLevel.Warn, + message = "baz", + exception = None, + context = Map( + "shared" -> "log site", + "foo" -> "1", + "bar" -> "1" + ) + ) + ) + } + } + } + + loggerTest("LocalLogger respects disabled log levels") { (localLogger, testLogger) => + localLogger.withAddedContext(Map("foo" -> "1")) { + for { + _ <- testLogger.enableLoggingWithFinestLevel(LogLevel.Debug) + _ <- localLogger.trace("bar") + _ <- localLogger.debug("baz") + _ <- testLogger.disableLogging + _ <- localLogger.error("qux") + entries <- testLogger.output.loggedEntries + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + TestLogger.Entry( + loggerName = "test", + level = LogLevel.Debug, + message = "baz", + exception = None, + context = Map("foo" -> "1") + ) + ) + } + } + } + + loggerTest("context stored locally is visible to deprecated StructuredLogger view") { + (localLogger, testLogger) => + val deprecatedLogger = localLogger.asStructuredLogger: @nowarn("cat=deprecation") + val structuredLogger = deprecatedLogger.addContext("shared" -> "structured", "foo" -> "1") + localLogger.withAddedContext(Map("shared" -> "local", "bar" -> "1")) { + for { + _ <- structuredLogger.trace("baz") + entries <- testLogger.output.loggedEntries + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + TestLogger.Entry( + loggerName = "test", + level = LogLevel.Trace, + message = "baz", + exception = None, + context = Map( + "shared" -> "local", + "foo" -> "1", + "bar" -> "1" + ) + ) + ) + } + } + } +} diff --git a/core/shared/src/test/scala/org/typelevel/log4cats/TestLogger.scala b/core/shared/src/test/scala/org/typelevel/log4cats/TestLogger.scala new file mode 100644 index 00000000..91f2543b --- /dev/null +++ b/core/shared/src/test/scala/org/typelevel/log4cats/TestLogger.scala @@ -0,0 +1,179 @@ +/* + * 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.{Functor, Monad} +import cats.effect.unsafe.IORuntime +import cats.effect.{IO, Ref} +import cats.syntax.flatMap._ +import cats.syntax.functor._ +import cats.syntax.order._ +import org.typelevel.log4cats.extras.LogLevel + +import scala.collection.immutable.Queue + +// `finestLogLevel` of `None` means all logging is disabled` +final class TestLogger[F[_]] private ( + val output: TestLogger.Output[F], + name: String, + finestLogLevel: Ref[F, Option[LogLevel]] +)(implicit F: Monad[F]) + extends SelfAwareStructuredLogger[F] { + + /** Enables logging with the finest level enabled set to the given level. */ + def enableLoggingWithFinestLevel(level: LogLevel): F[Unit] = + finestLogLevel.set(Some(level)) + + /** + * Disables all logging. + * + * Does NOT reset logged entries. + */ + def disableLogging: F[Unit] = + finestLogLevel.set(None) + + private[this] def isLevelEnabled(level: LogLevel): F[Boolean] = + finestLogLevel.get.map(_.exists(level >= _)) + + def isErrorEnabled: F[Boolean] = isLevelEnabled(LogLevel.Error) + def isWarnEnabled: F[Boolean] = isLevelEnabled(LogLevel.Warn) + def isInfoEnabled: F[Boolean] = isLevelEnabled(LogLevel.Info) + def isDebugEnabled: F[Boolean] = isLevelEnabled(LogLevel.Debug) + def isTraceEnabled: F[Boolean] = isLevelEnabled(LogLevel.Trace) + + protected[this] def doLog( + level: LogLevel, + exception: Option[Throwable], + ctx: Map[String, String] + )(msg: => String): F[Unit] = + F.ifM(isLevelEnabled(level))( + output.append( + TestLogger.Entry( + loggerName = name, + level = level, + message = msg, + exception = exception, + context = ctx + ) + ), + F.unit + ) + + def error(message: => String): F[Unit] = + doLog(LogLevel.Error, None, Map.empty)(message) + def error(t: Throwable)(message: => String): F[Unit] = + doLog(LogLevel.Error, Some(t), Map.empty)(message) + def error(ctx: Map[String, String])(msg: => String): F[Unit] = + doLog(LogLevel.Error, None, ctx)(msg) + def error(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + doLog(LogLevel.Error, Some(t), ctx)(msg) + + def warn(message: => String): F[Unit] = + doLog(LogLevel.Warn, None, Map.empty)(message) + def warn(t: Throwable)(message: => String): F[Unit] = + doLog(LogLevel.Warn, Some(t), Map.empty)(message) + def warn(ctx: Map[String, String])(msg: => String): F[Unit] = + doLog(LogLevel.Warn, None, ctx)(msg) + def warn(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + doLog(LogLevel.Warn, Some(t), ctx)(msg) + + def info(message: => String): F[Unit] = + doLog(LogLevel.Info, None, Map.empty)(message) + def info(t: Throwable)(message: => String): F[Unit] = + doLog(LogLevel.Info, Some(t), Map.empty)(message) + def info(ctx: Map[String, String])(msg: => String): F[Unit] = + doLog(LogLevel.Info, None, ctx)(msg) + def info(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + doLog(LogLevel.Info, Some(t), ctx)(msg) + + def debug(message: => String): F[Unit] = + doLog(LogLevel.Debug, None, Map.empty)(message) + def debug(t: Throwable)(message: => String): F[Unit] = + doLog(LogLevel.Debug, Some(t), Map.empty)(message) + def debug(ctx: Map[String, String])(msg: => String): F[Unit] = + doLog(LogLevel.Debug, None, ctx)(msg) + def debug(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + doLog(LogLevel.Debug, Some(t), ctx)(msg) + + def trace(message: => String): F[Unit] = + doLog(LogLevel.Trace, None, Map.empty)(message) + def trace(t: Throwable)(message: => String): F[Unit] = + doLog(LogLevel.Trace, Some(t), Map.empty)(message) + def trace(ctx: Map[String, String])(msg: => String): F[Unit] = + doLog(LogLevel.Trace, None, ctx)(msg) + def trace(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = + doLog(LogLevel.Trace, Some(t), ctx)(msg) +} + +object TestLogger { + final case class Entry( + loggerName: String, + level: LogLevel, + message: String, + exception: Option[Throwable], + context: Map[String, String] + ) + + /** Output from logging. */ + final class Output[F[_]: Functor] private (logEntries: Ref[F, Queue[TestLogger.Entry]]) { + + private[TestLogger] def append(entry: Entry): F[Unit] = + logEntries.update(_ :+ entry) + + /** @return entries that have been logged */ + def loggedEntries: F[Seq[TestLogger.Entry]] = + logEntries.get.widen + + /** Removes all logged entries. */ + def resetLoggedEntries: F[Unit] = + logEntries.set(Queue.empty) + } + + object Output { + def apply[F[_]: Functor: Ref.Make]: F[Output[F]] = + Ref.of(Queue.empty[Entry]).map(new Output(_)) + } + + final class Factory private ( + val output: Output[IO] + )(implicit runtime: IORuntime) + extends LoggerFactory[IO] { + + // these should probably return the same instance for the same name, but + // they have no documentation or specification, and it's much easier for + // them not to + def fromName(name: String): IO[TestLogger[IO]] = + IO.ref(Some(LogLevel.Trace): Option[LogLevel]) + .map(new TestLogger(output, name, _)) + def getLoggerFromName(name: String): TestLogger[IO] = + fromName(name).unsafeRunSync() + } + + object Factory { + def apply()(implicit runtime: IORuntime): IO[Factory] = + Output[IO].map(new Factory(_)) + } + + def apply[F[_]: Monad: Ref.Make]( + name: String, + finestLevelEnabled: Option[LogLevel] = Some(LogLevel.Trace) + ): F[TestLogger[F]] = + for { + output <- Output[F] + finestLogLevel <- Ref.of(finestLevelEnabled) + } yield new TestLogger(output, name, finestLogLevel) +} From ff55ea5fc43a107bbd1d9d6d142516d1f3cbbbc2 Mon Sep 17 00:00:00 2001 From: Marissa Date: Tue, 2 Sep 2025 03:27:23 -0400 Subject: [PATCH 2/7] Sidestep scala 3 type inference bug --- .../main/scala/org/typelevel/log4cats/LocalLogContext.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala index 2d42e0d1..787c2f59 100644 --- a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala +++ b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala @@ -81,7 +81,8 @@ object LocalLogContext { def ask[E2 >: Map[String, String]]: F[E2] = asks .traverse(_.ask[Map[String, String]]) - .map(_.reduceLeft(_ ++ _)) + .map[Map[String, String]](_.reduceLeft(_ ++ _)) + .widen // tparam on `map` and `widen` to make scala 3 happy def prependLowPriority(ask: AskContext[F]): MultiAskContext[F] = new MultiAskContext(ask +: asks) def appendHighPriority(ask: AskContext[F]): MultiAskContext[F] = From 8e7dcbaa6c3e1ce98d79c5cb812c72a355e5414c Mon Sep 17 00:00:00 2001 From: Marissa Date: Thu, 4 Sep 2025 10:22:27 -0400 Subject: [PATCH 3/7] Use existing test loggers in `LocalLogger` tests --- .../typelevel/log4cats/LocalLoggerTest.scala | 176 ---------------- .../org/typelevel/log4cats/TestLogger.scala | 179 ---------------- .../typelevel/log4cats/LocalLoggerTest.scala | 194 ++++++++++++++++++ 3 files changed, 194 insertions(+), 355 deletions(-) delete mode 100644 core/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala delete mode 100644 core/shared/src/test/scala/org/typelevel/log4cats/TestLogger.scala create mode 100644 testing/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala diff --git a/core/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala b/core/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala deleted file mode 100644 index 1c22310d..00000000 --- a/core/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala +++ /dev/null @@ -1,176 +0,0 @@ -/* - * 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.effect.IO -import cats.mtl.Local -import munit.CatsEffectSuite -import org.typelevel.log4cats.extras.LogLevel - -import scala.annotation.nowarn - -class LocalLoggerTest extends CatsEffectSuite { - private[this] def localTest( - name: String - )(body: Local[IO, Map[String, String]] => IO[?]): Unit = - test(name) { - IO.local(Map.empty[String, String]).flatMap(body) - } - - private[this] def factoryTest( - name: String - )(body: (LocalLoggerFactory[IO], TestLogger.Output[IO]) => IO[?]): Unit = - localTest(name) { implicit local => - TestLogger.Factory().flatMap { factory => - body(LocalLoggerFactory.fromLocal(factory), factory.output) - } - } - - private[this] def loggerTest( - name: String - )(body: (LocalLogger[IO], TestLogger[IO]) => IO[?]): Unit = - localTest(name) { implicit local => - TestLogger[IO]("test").flatMap { logger => - body(LocalLogger.fromLocal(logger), logger) - } - } - - factoryTest("LocalLoggerFactory stores context locally") { (factory, output) => - factory.withAddedContext(Map("foo" -> "1")) { - for { - logger <- factory.fromName("test") - _ <- logger.info("bar") - entries <- output.loggedEntries - } yield { - assertEquals(entries.length, 1) - assertEquals( - entries.head, - TestLogger.Entry( - loggerName = "test", - level = LogLevel.Info, - message = "bar", - exception = None, - context = Map("foo" -> "1") - ) - ) - } - } - } - - factoryTest("context stored after creation of logger is visible to logger") { (factory, output) => - for { - logger <- factory.fromName("test") - _ <- factory.withAddedContext(Map("foo" -> "1")) { - for { - _ <- logger.error("bar") - entries <- output.loggedEntries - } yield { - assertEquals(entries.length, 1) - assertEquals( - entries.head, - TestLogger.Entry( - loggerName = "test", - level = LogLevel.Error, - message = "bar", - exception = None, - context = Map("foo" -> "1") - ) - ) - } - } - } yield () - } - - factoryTest("log site context overrides local context") { (factory, output) => - factory.withAddedContext("shared" -> "local", "foo" -> "1") { - for { - logger <- factory.fromName("test") - _ <- logger.warn(Map("shared" -> "log site", "bar" -> "1"))("baz") - entries <- output.loggedEntries - } yield { - assertEquals(entries.length, 1) - assertEquals( - entries.head, - TestLogger.Entry( - loggerName = "test", - level = LogLevel.Warn, - message = "baz", - exception = None, - context = Map( - "shared" -> "log site", - "foo" -> "1", - "bar" -> "1" - ) - ) - ) - } - } - } - - loggerTest("LocalLogger respects disabled log levels") { (localLogger, testLogger) => - localLogger.withAddedContext(Map("foo" -> "1")) { - for { - _ <- testLogger.enableLoggingWithFinestLevel(LogLevel.Debug) - _ <- localLogger.trace("bar") - _ <- localLogger.debug("baz") - _ <- testLogger.disableLogging - _ <- localLogger.error("qux") - entries <- testLogger.output.loggedEntries - } yield { - assertEquals(entries.length, 1) - assertEquals( - entries.head, - TestLogger.Entry( - loggerName = "test", - level = LogLevel.Debug, - message = "baz", - exception = None, - context = Map("foo" -> "1") - ) - ) - } - } - } - - loggerTest("context stored locally is visible to deprecated StructuredLogger view") { - (localLogger, testLogger) => - val deprecatedLogger = localLogger.asStructuredLogger: @nowarn("cat=deprecation") - val structuredLogger = deprecatedLogger.addContext("shared" -> "structured", "foo" -> "1") - localLogger.withAddedContext(Map("shared" -> "local", "bar" -> "1")) { - for { - _ <- structuredLogger.trace("baz") - entries <- testLogger.output.loggedEntries - } yield { - assertEquals(entries.length, 1) - assertEquals( - entries.head, - TestLogger.Entry( - loggerName = "test", - level = LogLevel.Trace, - message = "baz", - exception = None, - context = Map( - "shared" -> "local", - "foo" -> "1", - "bar" -> "1" - ) - ) - ) - } - } - } -} diff --git a/core/shared/src/test/scala/org/typelevel/log4cats/TestLogger.scala b/core/shared/src/test/scala/org/typelevel/log4cats/TestLogger.scala deleted file mode 100644 index 91f2543b..00000000 --- a/core/shared/src/test/scala/org/typelevel/log4cats/TestLogger.scala +++ /dev/null @@ -1,179 +0,0 @@ -/* - * 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.{Functor, Monad} -import cats.effect.unsafe.IORuntime -import cats.effect.{IO, Ref} -import cats.syntax.flatMap._ -import cats.syntax.functor._ -import cats.syntax.order._ -import org.typelevel.log4cats.extras.LogLevel - -import scala.collection.immutable.Queue - -// `finestLogLevel` of `None` means all logging is disabled` -final class TestLogger[F[_]] private ( - val output: TestLogger.Output[F], - name: String, - finestLogLevel: Ref[F, Option[LogLevel]] -)(implicit F: Monad[F]) - extends SelfAwareStructuredLogger[F] { - - /** Enables logging with the finest level enabled set to the given level. */ - def enableLoggingWithFinestLevel(level: LogLevel): F[Unit] = - finestLogLevel.set(Some(level)) - - /** - * Disables all logging. - * - * Does NOT reset logged entries. - */ - def disableLogging: F[Unit] = - finestLogLevel.set(None) - - private[this] def isLevelEnabled(level: LogLevel): F[Boolean] = - finestLogLevel.get.map(_.exists(level >= _)) - - def isErrorEnabled: F[Boolean] = isLevelEnabled(LogLevel.Error) - def isWarnEnabled: F[Boolean] = isLevelEnabled(LogLevel.Warn) - def isInfoEnabled: F[Boolean] = isLevelEnabled(LogLevel.Info) - def isDebugEnabled: F[Boolean] = isLevelEnabled(LogLevel.Debug) - def isTraceEnabled: F[Boolean] = isLevelEnabled(LogLevel.Trace) - - protected[this] def doLog( - level: LogLevel, - exception: Option[Throwable], - ctx: Map[String, String] - )(msg: => String): F[Unit] = - F.ifM(isLevelEnabled(level))( - output.append( - TestLogger.Entry( - loggerName = name, - level = level, - message = msg, - exception = exception, - context = ctx - ) - ), - F.unit - ) - - def error(message: => String): F[Unit] = - doLog(LogLevel.Error, None, Map.empty)(message) - def error(t: Throwable)(message: => String): F[Unit] = - doLog(LogLevel.Error, Some(t), Map.empty)(message) - def error(ctx: Map[String, String])(msg: => String): F[Unit] = - doLog(LogLevel.Error, None, ctx)(msg) - def error(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = - doLog(LogLevel.Error, Some(t), ctx)(msg) - - def warn(message: => String): F[Unit] = - doLog(LogLevel.Warn, None, Map.empty)(message) - def warn(t: Throwable)(message: => String): F[Unit] = - doLog(LogLevel.Warn, Some(t), Map.empty)(message) - def warn(ctx: Map[String, String])(msg: => String): F[Unit] = - doLog(LogLevel.Warn, None, ctx)(msg) - def warn(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = - doLog(LogLevel.Warn, Some(t), ctx)(msg) - - def info(message: => String): F[Unit] = - doLog(LogLevel.Info, None, Map.empty)(message) - def info(t: Throwable)(message: => String): F[Unit] = - doLog(LogLevel.Info, Some(t), Map.empty)(message) - def info(ctx: Map[String, String])(msg: => String): F[Unit] = - doLog(LogLevel.Info, None, ctx)(msg) - def info(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = - doLog(LogLevel.Info, Some(t), ctx)(msg) - - def debug(message: => String): F[Unit] = - doLog(LogLevel.Debug, None, Map.empty)(message) - def debug(t: Throwable)(message: => String): F[Unit] = - doLog(LogLevel.Debug, Some(t), Map.empty)(message) - def debug(ctx: Map[String, String])(msg: => String): F[Unit] = - doLog(LogLevel.Debug, None, ctx)(msg) - def debug(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = - doLog(LogLevel.Debug, Some(t), ctx)(msg) - - def trace(message: => String): F[Unit] = - doLog(LogLevel.Trace, None, Map.empty)(message) - def trace(t: Throwable)(message: => String): F[Unit] = - doLog(LogLevel.Trace, Some(t), Map.empty)(message) - def trace(ctx: Map[String, String])(msg: => String): F[Unit] = - doLog(LogLevel.Trace, None, ctx)(msg) - def trace(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] = - doLog(LogLevel.Trace, Some(t), ctx)(msg) -} - -object TestLogger { - final case class Entry( - loggerName: String, - level: LogLevel, - message: String, - exception: Option[Throwable], - context: Map[String, String] - ) - - /** Output from logging. */ - final class Output[F[_]: Functor] private (logEntries: Ref[F, Queue[TestLogger.Entry]]) { - - private[TestLogger] def append(entry: Entry): F[Unit] = - logEntries.update(_ :+ entry) - - /** @return entries that have been logged */ - def loggedEntries: F[Seq[TestLogger.Entry]] = - logEntries.get.widen - - /** Removes all logged entries. */ - def resetLoggedEntries: F[Unit] = - logEntries.set(Queue.empty) - } - - object Output { - def apply[F[_]: Functor: Ref.Make]: F[Output[F]] = - Ref.of(Queue.empty[Entry]).map(new Output(_)) - } - - final class Factory private ( - val output: Output[IO] - )(implicit runtime: IORuntime) - extends LoggerFactory[IO] { - - // these should probably return the same instance for the same name, but - // they have no documentation or specification, and it's much easier for - // them not to - def fromName(name: String): IO[TestLogger[IO]] = - IO.ref(Some(LogLevel.Trace): Option[LogLevel]) - .map(new TestLogger(output, name, _)) - def getLoggerFromName(name: String): TestLogger[IO] = - fromName(name).unsafeRunSync() - } - - object Factory { - def apply()(implicit runtime: IORuntime): IO[Factory] = - Output[IO].map(new Factory(_)) - } - - def apply[F[_]: Monad: Ref.Make]( - name: String, - finestLevelEnabled: Option[LogLevel] = Some(LogLevel.Trace) - ): F[TestLogger[F]] = - for { - output <- Output[F] - finestLogLevel <- Ref.of(finestLevelEnabled) - } yield new TestLogger(output, name, finestLogLevel) -} diff --git a/testing/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala b/testing/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala new file mode 100644 index 00000000..1c4e24c7 --- /dev/null +++ b/testing/shared/src/test/scala/org/typelevel/log4cats/LocalLoggerTest.scala @@ -0,0 +1,194 @@ +/* + * 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.effect.IO +import cats.mtl.Local +import munit.CatsEffectSuite +import org.typelevel.log4cats.testing.{StructuredTestingLogger, TestingLoggerFactory} + +import scala.annotation.nowarn + +class LocalLoggerTest extends CatsEffectSuite { + private[this] def localTest( + name: String + )(body: Local[IO, Map[String, String]] => IO[?]): Unit = + test(name) { + IO.local(Map.empty[String, String]).flatMap(body) + } + + private[this] def factoryTest( + name: String, + traceEnabled: Boolean = true, + debugEnabled: Boolean = true, + infoEnabled: Boolean = true, + warnEnabled: Boolean = true, + errorEnabled: Boolean = true + )(body: (LocalLoggerFactory[IO], TestingLoggerFactory[IO]) => IO[?]): Unit = + localTest(name) { implicit local => + TestingLoggerFactory + .ref[IO]( + traceEnabled = traceEnabled, + debugEnabled = debugEnabled, + infoEnabled = infoEnabled, + warnEnabled = warnEnabled, + errorEnabled = errorEnabled + ) + .flatMap { factory => + body(LocalLoggerFactory.fromLocal(factory), factory) + } + } + + private[this] def loggerTest( + name: String, + traceEnabled: Boolean = true, + debugEnabled: Boolean = true, + infoEnabled: Boolean = true, + warnEnabled: Boolean = true, + errorEnabled: Boolean = true + )(body: (LocalLogger[IO], StructuredTestingLogger[IO]) => IO[?]): Unit = + localTest(name) { implicit local => + StructuredTestingLogger + .ref[IO]( + traceEnabled = traceEnabled, + debugEnabled = debugEnabled, + infoEnabled = infoEnabled, + warnEnabled = warnEnabled, + errorEnabled = errorEnabled + ) + .flatMap { logger => + body(LocalLogger.fromLocal(logger), logger) + } + } + + factoryTest("LocalLoggerFactory stores context locally") { (localFactory, testFactory) => + localFactory.withAddedContext(Map("foo" -> "1")) { + for { + logger <- localFactory.fromName("test") + _ <- logger.info("bar") + entries <- testFactory.logged + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + TestingLoggerFactory.Info( + loggerName = "test", + message = "bar", + throwOpt = None, + ctx = Map("foo" -> "1") + ) + ) + } + } + } + + factoryTest("context stored after creation of logger is visible to logger") { + (localFactory, testFactory) => + for { + logger <- localFactory.fromName("test") + _ <- localFactory.withAddedContext(Map("foo" -> "1")) { + for { + _ <- logger.error("bar") + entries <- testFactory.logged + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + TestingLoggerFactory.Error( + loggerName = "test", + message = "bar", + throwOpt = None, + ctx = Map("foo" -> "1") + ) + ) + } + } + } yield () + } + + factoryTest("log site context overrides local context") { (localFactory, testFactory) => + localFactory.withAddedContext("shared" -> "local", "foo" -> "1") { + for { + logger <- localFactory.fromName("test") + _ <- logger.warn(Map("shared" -> "log site", "bar" -> "1"))("baz") + entries <- testFactory.logged + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + TestingLoggerFactory.Warn( + loggerName = "test", + message = "baz", + throwOpt = None, + ctx = Map( + "shared" -> "log site", + "foo" -> "1", + "bar" -> "1" + ) + ) + ) + } + } + } + + loggerTest("LocalLogger respects disabled log levels", traceEnabled = false) { + (localLogger, testLogger) => + localLogger.withAddedContext(Map("foo" -> "1")) { + for { + _ <- localLogger.trace("bar") + _ <- localLogger.debug("baz") + entries <- testLogger.logged + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + StructuredTestingLogger.DEBUG( + message = "baz", + throwOpt = None, + ctx = Map("foo" -> "1") + ) + ) + } + } + } + + loggerTest("context stored locally is visible to deprecated StructuredLogger view") { + (localLogger, testLogger) => + val deprecatedLogger = localLogger.asStructuredLogger: @nowarn("cat=deprecation") + val structuredLogger = deprecatedLogger.addContext("shared" -> "structured", "foo" -> "1") + localLogger.withAddedContext(Map("shared" -> "local", "bar" -> "1")) { + for { + _ <- structuredLogger.trace("baz") + entries <- testLogger.logged + } yield { + assertEquals(entries.length, 1) + assertEquals( + entries.head, + StructuredTestingLogger.TRACE( + message = "baz", + throwOpt = None, + ctx = Map( + "shared" -> "local", + "foo" -> "1", + "bar" -> "1" + ) + ) + ) + } + } + } +} From c770baa445015066d45675552240e02ea19ad1cc Mon Sep 17 00:00:00 2001 From: Marissa Date: Mon, 8 Sep 2025 14:24:00 -0400 Subject: [PATCH 4/7] Disable warning for `LocalLogger#mapK` Disable scala 3 warning for deprecating `LocalLogger#mapK`, which overrides a non-deprecated member from its parent. --- .../main/scala/org/typelevel/log4cats/LocalLogger.scala | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala index 01a72f8a..4060b2dd 100644 --- a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala +++ b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala @@ -17,8 +17,10 @@ package org.typelevel.log4cats import cats.mtl.{LiftKind, Local} -import cats.syntax.flatMap.* -import cats.{~>, Monad, Show} +import cats.syntax.flatMap._ +import cats.{Monad, Show, ~>} + +import scala.annotation.nowarn /** * A logger with [[cats.mtl.Local `Local`]] semantics. @@ -55,6 +57,7 @@ sealed trait LocalLogger[F[_]] extends SelfAwareLogger[F] { def trace(ctx: Map[String, String])(msg: => String): F[Unit] def trace(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] + @nowarn("msg=overrides concrete, non-deprecated definition") @deprecated("use `liftTo` instead", since = "log4cats 2.8.0") override def mapK[G[_]](fk: F ~> G): SelfAwareLogger[G] = super.mapK(fk) @@ -103,6 +106,7 @@ object LocalLogger { def isDebugEnabled: F[Boolean] = underlying.isDebugEnabled def isTraceEnabled: F[Boolean] = underlying.isTraceEnabled + @nowarn("msg=overrides concrete, non-deprecated definition") @deprecated("use `liftTo` instead", since = "log4cats 2.8.0") override def mapK[G[_]](fk: F ~> G): SelfAwareStructuredLogger[G] = super.mapK(fk) From e143c4841d723b9266aa0e7ad9929c84036c2663 Mon Sep 17 00:00:00 2001 From: Marissa Date: Mon, 8 Sep 2025 14:30:52 -0400 Subject: [PATCH 5/7] Fix import order Fix import order that IntelliJ messed up when automatically importing the `nowarn` annotation. --- .../src/main/scala/org/typelevel/log4cats/LocalLogger.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala index 4060b2dd..eb61b583 100644 --- a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala +++ b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala @@ -18,7 +18,7 @@ package org.typelevel.log4cats import cats.mtl.{LiftKind, Local} import cats.syntax.flatMap._ -import cats.{Monad, Show, ~>} +import cats.{~>, Monad, Show} import scala.annotation.nowarn From 3a64e8402de834c1f267ca18f9eef945bf66440d Mon Sep 17 00:00:00 2001 From: Marissa Date: Mon, 8 Sep 2025 14:54:40 -0400 Subject: [PATCH 6/7] fixup! Disable warning for `LocalLogger#mapK` Disable warning in build.sbt instead of source file. --- build.sbt | 7 +++++++ .../main/scala/org/typelevel/log4cats/LocalLogger.scala | 4 ---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/build.sbt b/build.sbt index dba952bc..16b5bbbe 100644 --- a/build.sbt +++ b/build.sbt @@ -54,6 +54,13 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) libraryDependencies ++= { if (tlIsScala3.value) Seq.empty else Seq("org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided) + }, + scalacOptions ++= { + if (!tlIsScala3.value) Seq.empty + else + Seq( + """-Wconf:src=org/typelevel/log4cats/LocalLogger.scala&msg=overrides concrete. non-deprecated definition:s""" + ) } ) .nativeSettings(commonNativeSettings) diff --git a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala index eb61b583..b9a0d623 100644 --- a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala +++ b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogger.scala @@ -20,8 +20,6 @@ import cats.mtl.{LiftKind, Local} import cats.syntax.flatMap._ import cats.{~>, Monad, Show} -import scala.annotation.nowarn - /** * A logger with [[cats.mtl.Local `Local`]] semantics. * @@ -57,7 +55,6 @@ sealed trait LocalLogger[F[_]] extends SelfAwareLogger[F] { def trace(ctx: Map[String, String])(msg: => String): F[Unit] def trace(ctx: Map[String, String], t: Throwable)(msg: => String): F[Unit] - @nowarn("msg=overrides concrete, non-deprecated definition") @deprecated("use `liftTo` instead", since = "log4cats 2.8.0") override def mapK[G[_]](fk: F ~> G): SelfAwareLogger[G] = super.mapK(fk) @@ -106,7 +103,6 @@ object LocalLogger { def isDebugEnabled: F[Boolean] = underlying.isDebugEnabled def isTraceEnabled: F[Boolean] = underlying.isTraceEnabled - @nowarn("msg=overrides concrete, non-deprecated definition") @deprecated("use `liftTo` instead", since = "log4cats 2.8.0") override def mapK[G[_]](fk: F ~> G): SelfAwareStructuredLogger[G] = super.mapK(fk) From 2d6d8cd51836a429b998be6ecfa234abd1ada2fb Mon Sep 17 00:00:00 2001 From: Marissa Date: Mon, 15 Sep 2025 10:44:40 -0400 Subject: [PATCH 7/7] Add scala-collection-compat --- build.sbt | 8 +++++--- .../scala/org/typelevel/log4cats/LocalLogContext.scala | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/build.sbt b/build.sbt index 16b5bbbe..4f85a490 100644 --- a/build.sbt +++ b/build.sbt @@ -29,9 +29,10 @@ ThisBuild / tlVersionIntroduced := Map("3" -> "2.1.1") val catsV = "2.13.0" val catsEffectV = "3.7.0-RC1" val catsMtlV = "1.6.0" -val slf4jV = "1.7.36" -val munitCatsEffectV = "2.2.0-RC1" val logbackClassicV = "1.2.13" +val munitCatsEffectV = "2.2.0-RC1" +val scalaCollectionCompatV = "2.13.0" +val slf4jV = "1.7.36" Global / onChangedBuildSource := ReloadOnSourceChanges @@ -49,7 +50,8 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) libraryDependencies ++= Seq( "org.typelevel" %%% "cats-core" % catsV, "org.typelevel" %%% "cats-effect-std" % catsEffectV, - "org.typelevel" %%% "cats-mtl" % catsMtlV + "org.typelevel" %%% "cats-mtl" % catsMtlV, + "org.scala-lang.modules" %% "scala-collection-compat" % scalaCollectionCompatV ), libraryDependencies ++= { if (tlIsScala3.value) Seq.empty diff --git a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala index 787c2f59..775e1bec 100644 --- a/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala +++ b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala @@ -21,7 +21,7 @@ import cats.syntax.functor.* import cats.syntax.traverse.* import cats.{Applicative, Show} -import scala.collection.immutable.ArraySeq +import scala.collection.compat.immutable.ArraySeq /** * Log context stored in a [[cats.mtl.Local `Local`]], as well as potentially additional log context