Skip to content

Conversation

@Jay-Lokhande
Copy link

@Jay-Lokhande Jay-Lokhande commented Jun 18, 2025

This PR's objective is to create a SAM type interface in log4cats

Comment on lines 19 to 23
final case class KernelLogLevel(name: String, value: Double) {
def namePadded: String = KernelLogLevel.padded(this)

KernelLogLevel.add(this)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (structure): I'm sick right now, but we should get on a call towards the end of next week and go over bincompat.

Case classes are wonderful for applications, but we're going to tend to avoid them because they don't play nicely with bincompat.

Copy link
Member

Choose a reason for hiding this comment

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

agreed 💯

Copy link
Contributor

Choose a reason for hiding this comment

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

Since we haven't been able to get on a call, this is what it would look like as a sealed trait, which is the usual way to avoid bincompat issues:

sealed trait KernelLogLevel {
  def value: Int
  def name: String
  def namePadded: String = name.padTo(5, ' ').mkString
}
object KernelLogLevel {
  implicit final val log4catsCatsInstances: Eq[KernelLogLevel] & Hash[KernelLogLevel] & Order[KernelLogLevel] & Show[KernelLogLevel] =
    new Hash[KernelLogLevel] with Order[KernelLogLevel] with Show[KernelLogLevel] {
      override def eqv(x: KernelLogLevel, y: KernelLogLevel): Boolean = x.value == y.value

      override def hash(x: KernelLogLevel): Int = x.value.##

      override def compare(x: KernelLogLevel, y: KernelLogLevel): Int = x.value.compare(y.value)

      override def show(t: KernelLogLevel): String = t.name
    }


  private final class LogLevel(override val name: String, override val value: Int) extends KernelLogLevel {
    override def toString: String = name

    override def hashCode(): Int = value.##

    override def equals(obj: Any): Boolean = obj match {
      case that: LogLevel => this.value == that.value
      case _ => false
    }
  }

  val Trace: KernelLogLevel = new LogLevel("TRACE", 100)
  val Debug: KernelLogLevel = new LogLevel("DEBUG", 200)
  val Info: KernelLogLevel = new LogLevel("INFO", 300)
  val Warn: KernelLogLevel = new LogLevel("WARN", 400)
  val Error: KernelLogLevel = new LogLevel("ERROR", 500)
  val Fatal: KernelLogLevel = new LogLevel("FATAL", 600)
}

In this case though, we should be safe to use a sealed trait + case objects, because the old LogLevel has been very stable. The downside is that, if we do need to add one, it'll break bin- & source-compat. The upside is we get comprehensiveness checks.

import cats.{Order, Show}
import cats.kernel.{Eq, Hash}

sealed abstract class KernelLogLevel(val name: String, val value: Int) {
  def namePadded: String = name.padTo(5, ' ').mkString

  override def toString: String = name
}
object KernelLogLevel {
  implicit final val log4catsCatsInstances: Eq[KernelLogLevel] & Hash[KernelLogLevel] & Order[KernelLogLevel] & Show[KernelLogLevel] =
    new Hash[KernelLogLevel] with Order[KernelLogLevel] with Show[KernelLogLevel] {
      override def eqv(x: KernelLogLevel, y: KernelLogLevel): Boolean = x == y

      override def hash(x: KernelLogLevel): Int = x.##

      override def compare(x: KernelLogLevel, y: KernelLogLevel): Int = x.value.compare(y.value)

      override def show(t: KernelLogLevel): String = t.name
    }

  case object Trace extends KernelLogLevel("TRACE", 100)
  case object Debug extends KernelLogLevel("DEBUG", 200)
  case object Info extends KernelLogLevel("INFO", 300)
  case object Warn extends KernelLogLevel("WARN", 400)
  case object Error extends KernelLogLevel("ERROR", 500)
  case object Fatal extends KernelLogLevel("FATAL", 600)
}

@iRevive iRevive mentioned this pull request Jun 20, 2025
15 tasks
@Jay-Lokhande
Copy link
Author

Thank you for all the suggestions, I will rework on it

@Jay-Lokhande
Copy link
Author

@morgen-peschke just a gentle reminder regarding the PR review whenever you get a chance. I understand things can get busy thank you for your time and help!

build.sbt Outdated
Comment on lines 71 to 81
// Added new kernel methods - these are new API additions
ProblemFilters.exclude[ReversedMissingMethodProblem]("org.typelevel.log4cats.Logger.kernel"),
ProblemFilters.exclude[ReversedMissingMethodProblem](
"org.typelevel.log4cats.extras.DeferredLogger.kernel"
),
ProblemFilters.exclude[ReversedMissingMethodProblem](
"org.typelevel.log4cats.extras.DeferredSelfAwareStructuredLogger.kernel"
),
ProblemFilters.exclude[ReversedMissingMethodProblem](
"org.typelevel.log4cats.extras.DeferredStructuredLogger.kernel"
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Bumping tlBaseVersion will tell MiMa that you're going to do a minor version bump, and these will go away.

ThisBuild / tlBaseVersion := "2.8"

* A SAM-based Logger that provides a user-friendly interface. This is the new design that will
* eventually replace the current Logger trait.
*/
trait SamLogger[F[_], Ctx] {
Copy link
Contributor

Choose a reason for hiding this comment

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

cleanup: compare with Logger and you'll see they're basically identical so you can remove SamLogger

* This implementation uses the new SAM LoggerKernel design for better performance and middleware
* compatibility.
*/
trait SamStructuredLogger[F[_]] extends Logger[F] {
Copy link
Contributor

Choose a reason for hiding this comment

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

cleanup: compare with StructuredLogger and you'll see they're basically identical (especially their APIs) so this one can be deleted

Comment on lines 39 to 48
final def logTrace(record: Log.Builder => Log.Builder): F[Unit] =
log(KernelLogLevel.Trace, record)
final def logDebug(record: Log.Builder => Log.Builder): F[Unit] =
log(KernelLogLevel.Debug, record)
final def logInfo(record: Log.Builder => Log.Builder): F[Unit] =
log(KernelLogLevel.Info, record)
final def logWarn(record: Log.Builder => Log.Builder): F[Unit] =
log(KernelLogLevel.Warn, record)
final def logError(record: Log.Builder => Log.Builder): F[Unit] =
log(KernelLogLevel.Error, record)
Copy link
Contributor

Choose a reason for hiding this comment

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

These are only used by SamStructuredLogger, which can be removed now that StructuredLogger delegates to LoggerKernel.

Now would be a good time to remove them.

Comment on lines 34 to 37
protected def kernel: LoggerKernel[F, String] = new LoggerKernel[F, String] {
def log(level: KernelLogLevel, record: Log.Builder[String] => Log.Builder[String]): F[Unit] =
Applicative[F].pure(())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

cleanup: use the implementation you already have

Suggested change
protected def kernel: LoggerKernel[F, String] = new LoggerKernel[F, String] {
def log(level: KernelLogLevel, record: Log.Builder[String] => Log.Builder[String]): F[Unit] =
Applicative[F].pure(())
}
protected def kernel: LoggerKernel[F, String] = NoOpLoggerKernel[F, String]

Comment on lines 47 to 49
protected def kernel: LoggerKernel[F, String] = new LoggerKernel[F, String] {
def log(level: KernelLogLevel, record: Log.Builder[String] => Log.Builder[String]): F[Unit] =
Applicative[F].pure(())
Copy link
Contributor

Choose a reason for hiding this comment

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

concern: This loses the strict eval behavior (might be worth writing a test to catch this regression before fixing)

One way to solve this would be to add something like this to NoOpLoggerKernel:

def strict[F[_]: Applicative, Ctx]: LoggerKernel[F, Ctx] = new LoggerKernel[F, Ctx] {
  override def log(level: KernelLogLevel, record: Log.Builder[Ctx] => Log.Builder[Ctx]): F[Unit] = {
    val _ = record(Log.strictNoOpBuilder[Ctx])
    Applicative[F].unit
  }
}

Which would require adding strictNoOpBuilder to Log, which is pretty straightforward (the critical bit is in the implementation of withMessage, comparing to the implementation of NoOpLogger.strictImpl#void on main may be informative):

  private class NoOpBuilder[Ctx] extends Builder[Ctx] {
    override def withTimestamp(value: FiniteDuration): Log.Builder[Ctx] = this

    override def withLevel(level: KernelLogLevel): Log.Builder[Ctx] = this

    override def withMessage(message: => String): Log.Builder[Ctx] = {
      val _ = message
      this
    }

    override def withThrowable(throwable: Throwable): Log.Builder[Ctx] = this

    override def withContext[A](name: String)(ctx: A)(implicit E: Context.Encoder[A, Ctx]): Log.Builder[Ctx] = this

    override def withFileName(name: String): Log.Builder[Ctx] = this

    override def withClassName(name: String): Log.Builder[Ctx] = this

    override def withMethodName(name: String): Log.Builder[Ctx] = this

    override def withLine(line: Int): Log.Builder[Ctx] = this

    override def adaptTimestamp(f: FiniteDuration => FiniteDuration): Log.Builder[Ctx] = this

    override def adaptLevel(f: KernelLogLevel => KernelLogLevel): Log.Builder[Ctx] = this

    override def adaptMessage(f: String => String): Log.Builder[Ctx] = this

    override def adaptThrowable(f: Throwable => Throwable): Log.Builder[Ctx] = this

    override def adaptContext(f: Map[String, Ctx] => Map[String, Ctx]): Log.Builder[Ctx] = this

    override def adaptFileName(f: String => String): Log.Builder[Ctx] = this

    override def adaptClassName(f: String => String): Log.Builder[Ctx] = this

    override def adaptMethodName(f: String => String): Log.Builder[Ctx] = this

    override def adaptLine(f: Int => Int): Log.Builder[Ctx] = this

    override def build(): Log[Ctx] = new Log[Ctx] {
      override def timestamp: Option[FiniteDuration] = None

      override def level: KernelLogLevel = KernelLogLevel.Info

      override def message: () => String = () => ""

      override def throwable: Option[Throwable] = None

      override def context: Map[String, Ctx] = Map.empty

      override def fileName: Option[String] = None

      override def className: Option[String] = None

      override def methodName: Option[String] = None

      override def line: Option[Int] = None

      override def levelValue: Int = KernelLogLevel.Info.value
    }
  }

def isWarnEnabled: F[Boolean] = Sync[F].pure(warnEnabled)
def isErrorEnabled: F[Boolean] = Sync[F].pure(errorEnabled)

protected def kernel: LoggerKernel[F, String] = new LoggerKernel[F, String] {
Copy link
Contributor

Choose a reason for hiding this comment

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

cleanup: the loggers should already route everything through their kernels, so most of the methods in the testing loggers should be able to be removed and be implemented using a TestingLoggerKernel

Copy link
Contributor

Choose a reason for hiding this comment

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

The Sam* loggers can be removed, since they're dups of the old interfaces, but I wanted to highlight something important:

because of this we will lose

Structured Logging in SamLogger
...
Throwable Logging

This is intentional, otherwise the types won't help us avoid using capabilities our logger doesn't support.

It might not seem so bad to quietly log only what the logger can support - until you're trying to resolve a customer issue, only to find out the info you need is missing because the context part of the log was dropped 😢

Comment on lines 19 to 23
final case class KernelLogLevel(name: String, value: Double) {
def namePadded: String = KernelLogLevel.padded(this)

KernelLogLevel.add(this)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we haven't been able to get on a call, this is what it would look like as a sealed trait, which is the usual way to avoid bincompat issues:

sealed trait KernelLogLevel {
  def value: Int
  def name: String
  def namePadded: String = name.padTo(5, ' ').mkString
}
object KernelLogLevel {
  implicit final val log4catsCatsInstances: Eq[KernelLogLevel] & Hash[KernelLogLevel] & Order[KernelLogLevel] & Show[KernelLogLevel] =
    new Hash[KernelLogLevel] with Order[KernelLogLevel] with Show[KernelLogLevel] {
      override def eqv(x: KernelLogLevel, y: KernelLogLevel): Boolean = x.value == y.value

      override def hash(x: KernelLogLevel): Int = x.value.##

      override def compare(x: KernelLogLevel, y: KernelLogLevel): Int = x.value.compare(y.value)

      override def show(t: KernelLogLevel): String = t.name
    }


  private final class LogLevel(override val name: String, override val value: Int) extends KernelLogLevel {
    override def toString: String = name

    override def hashCode(): Int = value.##

    override def equals(obj: Any): Boolean = obj match {
      case that: LogLevel => this.value == that.value
      case _ => false
    }
  }

  val Trace: KernelLogLevel = new LogLevel("TRACE", 100)
  val Debug: KernelLogLevel = new LogLevel("DEBUG", 200)
  val Info: KernelLogLevel = new LogLevel("INFO", 300)
  val Warn: KernelLogLevel = new LogLevel("WARN", 400)
  val Error: KernelLogLevel = new LogLevel("ERROR", 500)
  val Fatal: KernelLogLevel = new LogLevel("FATAL", 600)
}

In this case though, we should be safe to use a sealed trait + case objects, because the old LogLevel has been very stable. The downside is that, if we do need to add one, it'll break bin- & source-compat. The upside is we get comprehensiveness checks.

import cats.{Order, Show}
import cats.kernel.{Eq, Hash}

sealed abstract class KernelLogLevel(val name: String, val value: Int) {
  def namePadded: String = name.padTo(5, ' ').mkString

  override def toString: String = name
}
object KernelLogLevel {
  implicit final val log4catsCatsInstances: Eq[KernelLogLevel] & Hash[KernelLogLevel] & Order[KernelLogLevel] & Show[KernelLogLevel] =
    new Hash[KernelLogLevel] with Order[KernelLogLevel] with Show[KernelLogLevel] {
      override def eqv(x: KernelLogLevel, y: KernelLogLevel): Boolean = x == y

      override def hash(x: KernelLogLevel): Int = x.##

      override def compare(x: KernelLogLevel, y: KernelLogLevel): Int = x.value.compare(y.value)

      override def show(t: KernelLogLevel): String = t.name
    }

  case object Trace extends KernelLogLevel("TRACE", 100)
  case object Debug extends KernelLogLevel("DEBUG", 200)
  case object Info extends KernelLogLevel("INFO", 300)
  case object Warn extends KernelLogLevel("WARN", 400)
  case object Error extends KernelLogLevel("ERROR", 500)
  case object Fatal extends KernelLogLevel("FATAL", 600)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants