From 6999f02da07a86b94f5804010338fce8d16fd3f2 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Thu, 2 May 2024 18:25:12 -0500 Subject: [PATCH] (patch) work in progress --- .pre-commit-config.yaml | 2 +- .scalafmt.conf | 2 +- build.sbt | 4 +- .../main/scala/gs/log/v0/DataRenderer.scala | 57 ++++++++++++++ .../api/src/main/scala/gs/log/v0/Log.scala | 74 ++++++++++++++++++ .../src/main/scala/gs/log/v0/LogData.scala | 77 +++++++++++++++++-- .../src/main/scala/gs/log/v0/LogLevel.scala | 4 +- .../src/main/scala/gs/log/v0/LogMessage.scala | 4 +- .../src/main/scala/gs/log/v0/Loggable.scala | 19 +++-- .../api/src/main/scala/gs/log/v0/Logger.scala | 58 ++++++++++++++ .../scala/gs/log/v0/LogMessageTests.scala | 2 +- project/plugins.sbt | 2 +- 12 files changed, 278 insertions(+), 27 deletions(-) create mode 100644 modules/api/src/main/scala/gs/log/v0/DataRenderer.scala diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2e8cfc..d3cafd8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,6 +12,6 @@ repos: - id: trailing-whitespace - id: check-yaml - repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala - rev: v1.0.0 + rev: v1.0.1 hooks: - id: scalafmt diff --git a/.scalafmt.conf b/.scalafmt.conf index 80e6cc9..9c7929b 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,5 +1,5 @@ // See: https://github.com/scalameta/scalafmt/tags for the latest tags. -version = 3.7.17 +version = 3.8.1 runner.dialect = scala3 maxColumn = 80 diff --git a/build.sbt b/build.sbt index 87e8758..6dc740d 100644 --- a/build.sbt +++ b/build.sbt @@ -22,7 +22,7 @@ val sharedSettings = Seq( lazy val testSettings = Seq( libraryDependencies ++= Seq( - "org.scalameta" %% "munit" % "1.0.0-M10" % Test + "org.scalameta" %% "munit" % "1.0.0-M12" % Test ) ) @@ -43,6 +43,6 @@ lazy val api = project .settings(name := s"${gsProjectName.value}-api-v${semVerMajor.value}") .settings( libraryDependencies ++= Seq( - "gs" %% "gs-uuid-v0" % "0.2.2" + "gs" %% "gs-uuid-v0" % "0.2.3" ) ) diff --git a/modules/api/src/main/scala/gs/log/v0/DataRenderer.scala b/modules/api/src/main/scala/gs/log/v0/DataRenderer.scala new file mode 100644 index 0000000..76fe50c --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/DataRenderer.scala @@ -0,0 +1,57 @@ +package gs.log.v0 + +import scala.reflect.ClassTag +import java.time.LocalDate +import java.time.Instant + +trait DataRenderer: + def render[A](data: A)(using CT: ClassTag[A]): String + +object DataRenderer: + + abstract class SingleTypeRenderer[A]: + def render(data: A): String + + object Defaults: + + final class StringRenderer extends SingleTypeRenderer[String]: + override def render(data: String): String = data + + final class BooleanRenderer extends SingleTypeRenderer[Boolean]: + override def render(data: Boolean): String = data.toString() + + final class IntRenderer extends SingleTypeRenderer[Int]: + override def render(data: Int): String = data.toString() + + final class LongRenderer extends SingleTypeRenderer[Long]: + override def render(data: Long): String = data.toString() + + final class FloatRenderer extends SingleTypeRenderer[Float]: + override def render(data: Float): String = data.toString() + + final class DoubleRenderer extends SingleTypeRenderer[Double]: + override def render(data: Double): String = data.toString() + + final class DateRenderer extends SingleTypeRenderer[LocalDate]: + override def render(data: LocalDate): String = data.toString() + + final class InstantRenderer extends SingleTypeRenderer[Instant]: + override def render(data: Instant): String = data.toString() + + end Defaults + + class RegistryRenderer( + private val registry: Map[Class[?], SingleTypeRenderer[?]] + ): + + def render[A](data: A)(using CT: ClassTag[A]): String = + getRendererFor[A].render(data) + + private def getRendererFor[A](using CT: ClassTag[A]): SingleTypeRenderer[A] = + Option(registry.get(CT.runtimeClass)) match + case Some(r) => r.asInstanceOf[SingleTypeRenderer[A]] + case None => throw new IllegalArgumentException("No renderer in registry!") + + end RegistryRenderer + +end DataRenderer diff --git a/modules/api/src/main/scala/gs/log/v0/Log.scala b/modules/api/src/main/scala/gs/log/v0/Log.scala index 2422841..053f764 100644 --- a/modules/api/src/main/scala/gs/log/v0/Log.scala +++ b/modules/api/src/main/scala/gs/log/v0/Log.scala @@ -19,24 +19,55 @@ final class Log( private var internalMessage: Option[LogMessage] = None, private var internalException: Option[Throwable] = None ): + /** + * Set the value for a single key. + * + * @param key The key to set. + * @param value The safe value for the key. + * @return This [[Log]]. + */ def data(key: LogData.Key, value: LogData.Safe): Log = val _ = this.internalData.put(key, value) this + /** + * Set a list of key/value pairs. + * + * @param kvp The list of key/value pairs. + * @return This [[Log]]. + */ def data(kvp: (LogData.Key, LogData.Safe)*): Log = val _ = kvp.foreach { case (k, v) => val _ = this.internalData.put(k, v) } this + /** + * Replace the current list of tags with a new list of tags. + * + * @param newTags The new list of tags for this log. + * @return This [[Log]]. + */ def tagged(newTags: String*): Log = this.internalTags = newTags.toList this + /** + * Replace the current message with a new message. + * + * @param newMessage The new message. + * @return This [[Log]]. + */ def message(newMessage: LogMessage): Log = this.internalMessage = Some(newMessage) this + /** + * Replace the current exception with a new exception. + * + * @param ex The new exception. + * @return This [[Log]]. + */ def exception(ex: Throwable): Log = this.internalException = Some(ex) this @@ -49,19 +80,62 @@ final class Log( def getException: Option[Throwable] = internalException + /** + * Determine if _any_ of the fields on this [[Log]] are set. Logs that are + * empty are always discarded. + * + * @return Whether or not this [[Log]] is empty. + */ + def isEmpty: Boolean = + internalData.isEmpty + && internalTags.isEmpty + && internalMessage.isEmpty + && internalException.isEmpty + object Log: + /** + * Instantiate a new log with the given key/value pair. + * + * @param key The key. + * @param value The value. + * @return New [[Log]] instance. + */ def data(key: LogData.Key, value: LogData.Safe): Log = new Log().data(key, value) + /** + * Instantiate a new log with the given list of key/value pairs. + * + * @param kvp The list of key/value pairs. + * @return New [[Log]] instance. + */ def data(kvp: (LogData.Key, LogData.Safe)*): Log = new Log().data(kvp*) + /** + * Instantiate a new log with the given list of tags. + * + * @param tags The list of tags. + * @return New [[Log]] instance. + */ def tagged(tags: String*): Log = new Log().tagged(tags*) + /** + * Instantiate a new log with the given message. + * + * @param tags The message. + * @return New [[Log]] instance. + */ def message(msg: LogMessage): Log = new Log().message(msg) + /** + * Instantiate a new log with the given exception. + * + * @param tags The exception. + * @return New [[Log]] instance. + */ def exception(ex: Throwable): Log = new Log().exception(ex) diff --git a/modules/api/src/main/scala/gs/log/v0/LogData.scala b/modules/api/src/main/scala/gs/log/v0/LogData.scala index c4f1d22..b0cc474 100644 --- a/modules/api/src/main/scala/gs/log/v0/LogData.scala +++ b/modules/api/src/main/scala/gs/log/v0/LogData.scala @@ -1,5 +1,7 @@ package gs.log.v0 +import java.time.LocalDate + object LogData: /** Opaque type (String) that represents a key that identifies some piece of @@ -39,31 +41,34 @@ object LogData: * The data to log. */ def data: A - def render(): String case class Str(data: String) extends Value: override type A = String - override def render(): String = data.toString() case class Bool(data: Boolean) extends Value: override type A = Boolean - override def render(): String = data.toString() case class Int32(data: Int) extends Value: override type A = Int - override def render(): String = data.toString() case class Int64(data: Long) extends Value: override type A = Long - override def render(): String = data.toString() case class Float32(data: Float) extends Value: override type A = Float - override def render(): String = data.toString() case class Float64(data: Double) extends Value: override type A = Double - override def render(): String = data.toString() + + case class Date(data: LocalDate) extends Value: + override type A = LocalDate + + case class Instant(data: java.time.Instant) extends Value: + override type A = java.time.Instant + + // TODO: IS THIS APPROPRIATE? + case class Generic[AA](data: AA) extends Value: + override type A = AA /** Indicates data (in terms of [[Value]]) that has been explicitly marked as * _safe_ to log in some form. @@ -157,12 +162,70 @@ object LogData: end RequiresSecureHash + /** The specified data is safe to log in clear text. This means that it + * will show up in output and be human-readable. Use this for values that + * are not sensitive. + * + * If you have configured a log index where it is considered safe to log + * all values (e.g. PHI) in clear text, you still should _NOT_ use this + * type! In those cases, still mark the data appropriately + * (encrypted/hashed) but use engine configuration to disable those + * functions and log in clear text. This practice provides better + * auditing and portability. + * + * @param value + * The value that is safe to log in clear text. + */ def logClearText[A: Loggable](data: A): ClearText = ClearText(Loggable[A].renderForLogs(data)) + /** The specified data is _not_ safe to log in clear text, and must be + * encrypted before being output. In this use case, the data is valuable + * to have available for debugging/investigation, but it is not safe to + * include in the clear. Users must _decrypt_ the data before using it. + * + * Note that `gs-log` does not provide a general decryption tool, nor + * should it! It is your responsibility to provide a secure way to + * decrypt sensitive information extracted from logs. + * + * As an alternative, if you have a log index that is certified to store + * all types of data in the clear due to other protections, use this type + * but turn off encryption in engine configuration. + * + * This type is not suitable for data that needs to be _searchable_. For + * that use case, please refer to [[RequiresSecureHash]]. + * + * Example Algorithm: AES GCM (256-bit) + * + * @param value + * The value to encrypt. + */ def logEncrypted[A: Loggable](data: A): RequiresEncryption = RequiresEncryption(Loggable[A].renderForLogs(data)) + /** The contianed data is _not_ safe to log in clear text, and must be + * hashed before being output. In this use case, the data is valuable to + * be searched upon to narrow results, but is not safe to include in the + * clear. Users must generate a hash using the same parameters to search. + * + * For example, consider a case where in rare cases certain customers + * have isolated issues and searching by first and last name is useful + * for narrowing results. In this case, the first and last names are + * hashed in the log output -- the user must select a user, hash their + * first and last name, and enter those hashes as search criteria. + * + * As an alternative, if you have a log index that is certified to store + * all types of data in the clear due to other protections, use this type + * but turn off hashing in engine configuration. + * + * This type is not suitable for data that needs to be _decrypted_. For + * that use case, please refer to [[RequiresEncryption]]. + * + * Example algorithm: HMAC + SHA-256 + * + * @param value + * The value to hash. + */ def logHashed[A: Loggable](data: A): RequiresSecureHash = RequiresSecureHash(Loggable[A].renderForLogs(data)) diff --git a/modules/api/src/main/scala/gs/log/v0/LogLevel.scala b/modules/api/src/main/scala/gs/log/v0/LogLevel.scala index 2c291be..b6a8b24 100644 --- a/modules/api/src/main/scala/gs/log/v0/LogLevel.scala +++ b/modules/api/src/main/scala/gs/log/v0/LogLevel.scala @@ -24,6 +24,8 @@ sealed abstract class LogLevel( override def compare(that: LogLevel): Int = ordinal - that.ordinal object LogLevel: + given CanEqual[LogLevel, LogLevel] = CanEqual.derived + /** Most-detailed log level. This level should (likely) not be turned on most * of the time. It should be used to provide extremely verbose output. */ @@ -59,6 +61,4 @@ object LogLevel: /** Indicates that logging is completely disabled. */ case object Off extends LogLevel("off", -1) - - given CanEqual[LogLevel, LogLevel] = CanEqual.derived end LogLevel diff --git a/modules/api/src/main/scala/gs/log/v0/LogMessage.scala b/modules/api/src/main/scala/gs/log/v0/LogMessage.scala index 975a5a4..bc618a7 100644 --- a/modules/api/src/main/scala/gs/log/v0/LogMessage.scala +++ b/modules/api/src/main/scala/gs/log/v0/LogMessage.scala @@ -27,7 +27,7 @@ object LogMessage: trait Builder: - extension (sc: StringContext) + extension (sc: StringContext)(using R: DataRenderer) /** String interpolator for instantiating a [[LogMessage]]. * @@ -38,6 +38,6 @@ object LogMessage: * The new [[LogMessage]]. */ def log(args: LogData.ClearText*): LogMessage = - sc.s(args.map(_.value.render())*) + sc.s(args.map(d => R.render(d.value))*) end LogMessage diff --git a/modules/api/src/main/scala/gs/log/v0/Loggable.scala b/modules/api/src/main/scala/gs/log/v0/Loggable.scala index 5bfd735..c1e2d48 100644 --- a/modules/api/src/main/scala/gs/log/v0/Loggable.scala +++ b/modules/api/src/main/scala/gs/log/v0/Loggable.scala @@ -6,6 +6,13 @@ import java.time.LocalDate /** Type class for data that can be logged by the `gs-log` library. */ trait Loggable[A]: + /** + * Render the given data as some primitive supported by `gs-log`. Note that + * this type class does **not** support structured types. + * + * @param data The data to render. + * @return The [[LogData.Value]] housing the output primitive. + */ def renderForLogs(data: A): LogData.Value object Loggable: @@ -21,15 +28,7 @@ object Loggable: given Loggable[Long] = LogData.Int64(_) given Loggable[Float] = LogData.Float32(_) given Loggable[Double] = LogData.Float64(_) + given Loggable[LocalDate] = LogData.Date(_) + given Loggable[Instant] = LogData.Instant(_) - /** Default implementation for `java.time.LocalDate`. Uses the default - * `toString`, which formats the date as `uuuu-MM-dd` (ISO_LOCAL_DATE). - */ - def forDate(): Loggable[LocalDate] = data => LogData.Str(data.toString()) - - /** Default implementation for `java.time.Instant`. Uses the default - * `toString`, which formats the date using ISO_INSTANT. Produces strings - * that look like: `2011-12-03T10:15:30Z` - */ - def forInstant(): Loggable[Instant] = data => LogData.Str(data.toString()) end Loggable diff --git a/modules/api/src/main/scala/gs/log/v0/Logger.scala b/modules/api/src/main/scala/gs/log/v0/Logger.scala index 5dface0..d4ecb38 100644 --- a/modules/api/src/main/scala/gs/log/v0/Logger.scala +++ b/modules/api/src/main/scala/gs/log/v0/Logger.scala @@ -5,13 +5,65 @@ package gs.log.v0 * users of this library will interact with. */ trait Logger[F[_]]: + /** + * Emit the given [[Log]] at the [[LogLevel.Trace]] level. + * + * @param log The [[Log]] to emit. + * @return Side-effect. + */ def trace(log: => Log): F[Unit] + + /** + * Emit the given [[Log]] at the [[LogLevel.Debug]] level. + * + * @param log The [[Log]] to emit. + * @return Side-effect. + */ def debug(log: => Log): F[Unit] + + /** + * Emit the given [[Log]] at the [[LogLevel.Info]] level. + * + * @param log The [[Log]] to emit. + * @return Side-effect. + */ def info(log: => Log): F[Unit] + + /** + * Emit the given [[Log]] at the [[LogLevel.Warn]] level. + * + * @param log The [[Log]] to emit. + * @return Side-effect. + */ def warn(log: => Log): F[Unit] + + /** + * Emit the given [[Log]] at the [[LogLevel.Error]] level. + * + * @param log The [[Log]] to emit. + * @return Side-effect. + */ def error(log: => Log): F[Unit] + + /** + * Emit the given [[Log]] at the [[LogLevel.Fatal]] level. + * + * @param log The [[Log]] to emit. + * @return Side-effect. + */ def fatal(log: => Log): F[Unit] + + /** + * @return The name of this logger. + */ def name(): Logger.Name + + /** + * Determine whether this logger has the given level enabled. + * + * @param level The [[LogLevel]] to check. + * @return True if the level is enabled, false otherwise. + */ def isLevelEnabled(level: LogLevel): Boolean object Logger: @@ -24,6 +76,12 @@ object Logger: object Name: + /** + * Instantiate a new [[Name]] from the given string. + * + * @param loggerName The logger's name. + * @return + */ def apply(loggerName: String): Name = loggerName given CanEqual[Name, Name] = CanEqual.derived diff --git a/modules/api/src/test/scala/gs/log/v0/LogMessageTests.scala b/modules/api/src/test/scala/gs/log/v0/LogMessageTests.scala index bd04703..8bfe444 100644 --- a/modules/api/src/test/scala/gs/log/v0/LogMessageTests.scala +++ b/modules/api/src/test/scala/gs/log/v0/LogMessageTests.scala @@ -20,6 +20,6 @@ class LogMessageTests extends munit.FunSuite: val x = "foo".logClearText() val y = 10.logClearText() val msg: LogMessage = log"x = $x, y = $y" - val expected = s"x = ${x.value.render()}, y = ${y.value.render()}" + val expected = s"x = ${x.value.renderBytes()}, y = ${y.value.renderBytes()}" assert(msg.str() == expected) } diff --git a/project/plugins.sbt b/project/plugins.sbt index e897854..8830bf1 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -28,6 +28,6 @@ externalResolvers := Seq( "Garrity Software Releases" at "https://maven.garrity.co/gs" ) -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.11") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.12") addSbtPlugin("gs" % "sbt-garrity-software" % "0.3.0") addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0")