(patch) work in progress

This commit is contained in:
Pat Garrity 2024-05-02 18:25:12 -05:00
parent 7917cdf4dc
commit 6999f02da0
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
12 changed files with 278 additions and 27 deletions

View file

@ -12,6 +12,6 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
- id: check-yaml - id: check-yaml
- repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala - repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala
rev: v1.0.0 rev: v1.0.1
hooks: hooks:
- id: scalafmt - id: scalafmt

View file

@ -1,5 +1,5 @@
// See: https://github.com/scalameta/scalafmt/tags for the latest tags. // See: https://github.com/scalameta/scalafmt/tags for the latest tags.
version = 3.7.17 version = 3.8.1
runner.dialect = scala3 runner.dialect = scala3
maxColumn = 80 maxColumn = 80

View file

@ -22,7 +22,7 @@ val sharedSettings = Seq(
lazy val testSettings = Seq( lazy val testSettings = Seq(
libraryDependencies ++= 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(name := s"${gsProjectName.value}-api-v${semVerMajor.value}")
.settings( .settings(
libraryDependencies ++= Seq( libraryDependencies ++= Seq(
"gs" %% "gs-uuid-v0" % "0.2.2" "gs" %% "gs-uuid-v0" % "0.2.3"
) )
) )

View file

@ -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

View file

@ -19,24 +19,55 @@ final class Log(
private var internalMessage: Option[LogMessage] = None, private var internalMessage: Option[LogMessage] = None,
private var internalException: Option[Throwable] = 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 = def data(key: LogData.Key, value: LogData.Safe): Log =
val _ = this.internalData.put(key, value) val _ = this.internalData.put(key, value)
this 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 = def data(kvp: (LogData.Key, LogData.Safe)*): Log =
val _ = kvp.foreach { val _ = kvp.foreach {
case (k, v) => val _ = this.internalData.put(k, v) case (k, v) => val _ = this.internalData.put(k, v)
} }
this 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 = def tagged(newTags: String*): Log =
this.internalTags = newTags.toList this.internalTags = newTags.toList
this this
/**
* Replace the current message with a new message.
*
* @param newMessage The new message.
* @return This [[Log]].
*/
def message(newMessage: LogMessage): Log = def message(newMessage: LogMessage): Log =
this.internalMessage = Some(newMessage) this.internalMessage = Some(newMessage)
this this
/**
* Replace the current exception with a new exception.
*
* @param ex The new exception.
* @return This [[Log]].
*/
def exception(ex: Throwable): Log = def exception(ex: Throwable): Log =
this.internalException = Some(ex) this.internalException = Some(ex)
this this
@ -49,19 +80,62 @@ final class Log(
def getException: Option[Throwable] = internalException 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: 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 = def data(key: LogData.Key, value: LogData.Safe): Log =
new Log().data(key, value) 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 = def data(kvp: (LogData.Key, LogData.Safe)*): Log =
new Log().data(kvp*) 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 = def tagged(tags: String*): Log =
new Log().tagged(tags*) 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 = def message(msg: LogMessage): Log =
new Log().message(msg) 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 = def exception(ex: Throwable): Log =
new Log().exception(ex) new Log().exception(ex)

View file

@ -1,5 +1,7 @@
package gs.log.v0 package gs.log.v0
import java.time.LocalDate
object LogData: object LogData:
/** Opaque type (String) that represents a key that identifies some piece of /** Opaque type (String) that represents a key that identifies some piece of
@ -39,31 +41,34 @@ object LogData:
* The data to log. * The data to log.
*/ */
def data: A def data: A
def render(): String
case class Str(data: String) extends Value: case class Str(data: String) extends Value:
override type A = String override type A = String
override def render(): String = data.toString()
case class Bool(data: Boolean) extends Value: case class Bool(data: Boolean) extends Value:
override type A = Boolean override type A = Boolean
override def render(): String = data.toString()
case class Int32(data: Int) extends Value: case class Int32(data: Int) extends Value:
override type A = Int override type A = Int
override def render(): String = data.toString()
case class Int64(data: Long) extends Value: case class Int64(data: Long) extends Value:
override type A = Long override type A = Long
override def render(): String = data.toString()
case class Float32(data: Float) extends Value: case class Float32(data: Float) extends Value:
override type A = Float override type A = Float
override def render(): String = data.toString()
case class Float64(data: Double) extends Value: case class Float64(data: Double) extends Value:
override type A = Double 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 /** Indicates data (in terms of [[Value]]) that has been explicitly marked as
* _safe_ to log in some form. * _safe_ to log in some form.
@ -157,12 +162,70 @@ object LogData:
end RequiresSecureHash 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 = def logClearText[A: Loggable](data: A): ClearText =
ClearText(Loggable[A].renderForLogs(data)) 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 = def logEncrypted[A: Loggable](data: A): RequiresEncryption =
RequiresEncryption(Loggable[A].renderForLogs(data)) 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 = def logHashed[A: Loggable](data: A): RequiresSecureHash =
RequiresSecureHash(Loggable[A].renderForLogs(data)) RequiresSecureHash(Loggable[A].renderForLogs(data))

View file

@ -24,6 +24,8 @@ sealed abstract class LogLevel(
override def compare(that: LogLevel): Int = ordinal - that.ordinal override def compare(that: LogLevel): Int = ordinal - that.ordinal
object LogLevel: object LogLevel:
given CanEqual[LogLevel, LogLevel] = CanEqual.derived
/** Most-detailed log level. This level should (likely) not be turned on most /** 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. * of the time. It should be used to provide extremely verbose output.
*/ */
@ -59,6 +61,4 @@ object LogLevel:
/** Indicates that logging is completely disabled. /** Indicates that logging is completely disabled.
*/ */
case object Off extends LogLevel("off", -1) case object Off extends LogLevel("off", -1)
given CanEqual[LogLevel, LogLevel] = CanEqual.derived
end LogLevel end LogLevel

View file

@ -27,7 +27,7 @@ object LogMessage:
trait Builder: trait Builder:
extension (sc: StringContext) extension (sc: StringContext)(using R: DataRenderer)
/** String interpolator for instantiating a [[LogMessage]]. /** String interpolator for instantiating a [[LogMessage]].
* *
@ -38,6 +38,6 @@ object LogMessage:
* The new [[LogMessage]]. * The new [[LogMessage]].
*/ */
def log(args: LogData.ClearText*): LogMessage = def log(args: LogData.ClearText*): LogMessage =
sc.s(args.map(_.value.render())*) sc.s(args.map(d => R.render(d.value))*)
end LogMessage end LogMessage

View file

@ -6,6 +6,13 @@ import java.time.LocalDate
/** Type class for data that can be logged by the `gs-log` library. /** Type class for data that can be logged by the `gs-log` library.
*/ */
trait Loggable[A]: 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 def renderForLogs(data: A): LogData.Value
object Loggable: object Loggable:
@ -21,15 +28,7 @@ object Loggable:
given Loggable[Long] = LogData.Int64(_) given Loggable[Long] = LogData.Int64(_)
given Loggable[Float] = LogData.Float32(_) given Loggable[Float] = LogData.Float32(_)
given Loggable[Double] = LogData.Float64(_) 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 end Loggable

View file

@ -5,13 +5,65 @@ package gs.log.v0
* users of this library will interact with. * users of this library will interact with.
*/ */
trait Logger[F[_]]: 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] 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] 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] 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] 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] 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] def fatal(log: => Log): F[Unit]
/**
* @return The name of this logger.
*/
def name(): Logger.Name 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 def isLevelEnabled(level: LogLevel): Boolean
object Logger: object Logger:
@ -24,6 +76,12 @@ object Logger:
object Name: object Name:
/**
* Instantiate a new [[Name]] from the given string.
*
* @param loggerName The logger's name.
* @return
*/
def apply(loggerName: String): Name = loggerName def apply(loggerName: String): Name = loggerName
given CanEqual[Name, Name] = CanEqual.derived given CanEqual[Name, Name] = CanEqual.derived

View file

@ -20,6 +20,6 @@ class LogMessageTests extends munit.FunSuite:
val x = "foo".logClearText() val x = "foo".logClearText()
val y = 10.logClearText() val y = 10.logClearText()
val msg: LogMessage = log"x = $x, y = $y" 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) assert(msg.str() == expected)
} }

View file

@ -28,6 +28,6 @@ externalResolvers := Seq(
"Garrity Software Releases" at "https://maven.garrity.co/gs" "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-garrity-software" % "0.3.0")
addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0") addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0")