diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df64a8d2..1c237fff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,12 +28,10 @@ jobs: strategy: matrix: os: [ubuntu-22.04] - scala: [2.13, 2.12, 3] + scala: [2.13, 3] java: [temurin@8, temurin@11] project: [rootJS, rootJVM, rootNative] exclude: - - scala: 2.12 - java: temurin@11 - scala: 3 java: temurin@11 - project: rootJS @@ -192,36 +190,6 @@ jobs: tar xf targets.tar rm targets.tar - - name: Download target directories (2.12, rootJS) - uses: actions/download-artifact@v4 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJS - - - name: Inflate target directories (2.12, rootJS) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (2.12, rootJVM) - uses: actions/download-artifact@v4 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJVM - - - name: Inflate target directories (2.12, rootJVM) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (2.12, rootNative) - uses: actions/download-artifact@v4 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootNative - - - name: Inflate target directories (2.12, rootNative) - run: | - tar xf targets.tar - rm targets.tar - - name: Download target directories (3, rootJS) uses: actions/download-artifact@v4 with: @@ -322,7 +290,7 @@ jobs: - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: - modules-ignore: rootjs_2.13 rootjs_2.12 rootjs_3 docs_2.13 docs_2.12 docs_3 rootjvm_2.13 rootjvm_2.12 rootjvm_3 rootnative_2.13 rootnative_2.12 rootnative_3 + modules-ignore: rootjs_2.13 rootjs_3 docs_2.13 docs_3 rootjvm_2.13 rootjvm_3 rootnative_2.13 rootnative_3 configs-ignore: test scala-tool scala-doc-tool test-internal validate-steward: diff --git a/build.sbt b/build.sbt index 73ab01e9..095b8ba5 100644 --- a/build.sbt +++ b/build.sbt @@ -1,11 +1,10 @@ import com.typesafe.tools.mima.core._ val Scala213 = "2.13.16" -val Scala212 = "2.12.20" val Scala3 = "3.3.6" ThisBuild / tlBaseVersion := "2.7" -ThisBuild / crossScalaVersions := Seq(Scala213, Scala212, Scala3) +ThisBuild / crossScalaVersions := Seq(Scala213, Scala3) ThisBuild / scalaVersion := Scala213 ThisBuild / startYear := Some(2018) ThisBuild / developers := List( @@ -28,6 +27,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,11 +47,19 @@ 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 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/LocalLogContext.scala b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala new file mode 100644 index 00000000..787c2f59 --- /dev/null +++ b/core/shared/src/main/scala/org/typelevel/log4cats/LocalLogContext.scala @@ -0,0 +1,135 @@ +/* + * 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[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] = + 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..6bd4c505 --- /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[A](ctx:Map[String,String])*]] + */ +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/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" + ) + ) + ) + } + } + } +}