(minor) Initial implementation of the logging API and core types.

This commit is contained in:
Pat Garrity 2024-04-19 08:04:23 -05:00
parent 4c7c478af4
commit 7917cdf4dc
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
12 changed files with 604 additions and 6 deletions

View file

@ -22,7 +22,7 @@ This artifact is available in the Garrity Software Maven repository.
externalResolvers += externalResolvers +=
"Garrity Software Releases" at "https://maven.garrity.co/gs" "Garrity Software Releases" at "https://maven.garrity.co/gs"
val GsHex: ModuleID = val GsLog: ModuleID =
"gs" %% "gs-log-v0" % "$VERSION" "gs" %% "gs-log-v0" % "$VERSION"
``` ```

View file

@ -1,14 +1,14 @@
val scala3: String = "3.4.1" val scala3: String = "3.4.1"
externalResolvers := Seq(
"Garrity Software Mirror" at "https://maven.garrity.co/releases",
"Garrity Software Releases" at "https://maven.garrity.co/gs"
)
ThisBuild / scalaVersion := scala3 ThisBuild / scalaVersion := scala3
ThisBuild / versionScheme := Some("semver-spec") ThisBuild / versionScheme := Some("semver-spec")
ThisBuild / gsProjectName := "gs-log" ThisBuild / gsProjectName := "gs-log"
ThisBuild / externalResolvers := Seq(
"Garrity Software Mirror" at "https://maven.garrity.co/releases",
"Garrity Software Releases" at "https://maven.garrity.co/gs"
)
val noPublishSettings = Seq( val noPublishSettings = Seq(
publish := {} publish := {}
) )
@ -32,8 +32,17 @@ lazy val `gs-log` = project
.settings(noPublishSettings) .settings(noPublishSettings)
.settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}")
/** This project contains the logging API and core data types required to use
* `gs-log`. It does not provide an engine. This project can be used within
* libraries, for example, or in conjunction with an engine.
*/
lazy val api = project lazy val api = project
.in(file("modules/api")) .in(file("modules/api"))
.settings(sharedSettings) .settings(sharedSettings)
.settings(testSettings) .settings(testSettings)
.settings(name := s"${gsProjectName.value}-api-v${semVerMajor.value}") .settings(name := s"${gsProjectName.value}-api-v${semVerMajor.value}")
.settings(
libraryDependencies ++= Seq(
"gs" %% "gs-uuid-v0" % "0.2.2"
)
)

View file

@ -0,0 +1,86 @@
package gs.log.v0
import java.time.Instant
import scala.collection.mutable.{Map => MutMap}
/**
* Representation of a Log - a package of data that can be rendered and emitted
* by a [[Logger]] and underlying engine. This type provides a fluent interface
* for constructing and logs.
*
* @param data Key/value mapping of data that is safe to log.
* @param tags List of tags (see: [[LogTags]]) applicable to this log.
* @param message Optional [[LogMessage]] to include in the output.
* @param exception Optional exception to include in the output.
*/
final class Log(
private val internalData: MutMap[LogData.Key, LogData.Safe] = MutMap.empty,
private var internalTags: List[String] = List.empty,
private var internalMessage: Option[LogMessage] = None,
private var internalException: Option[Throwable] = None
):
def data(key: LogData.Key, value: LogData.Safe): Log =
val _ = this.internalData.put(key, value)
this
def data(kvp: (LogData.Key, LogData.Safe)*): Log =
val _ = kvp.foreach {
case (k, v) => val _ = this.internalData.put(k, v)
}
this
def tagged(newTags: String*): Log =
this.internalTags = newTags.toList
this
def message(newMessage: LogMessage): Log =
this.internalMessage = Some(newMessage)
this
def exception(ex: Throwable): Log =
this.internalException = Some(ex)
this
def getData: Map[LogData.Key, LogData.Safe] = internalData.toMap
def getTags: List[String] = internalTags
def getMessage: Option[LogMessage] = internalMessage
def getException: Option[Throwable] = internalException
object Log:
def data(key: LogData.Key, value: LogData.Safe): Log =
new Log().data(key, value)
def data(kvp: (LogData.Key, LogData.Safe)*): Log =
new Log().data(kvp*)
def tagged(tags: String*): Log =
new Log().tagged(tags*)
def message(msg: LogMessage): Log =
new Log().message(msg)
def exception(ex: Throwable): Log =
new Log().exception(ex)
/** All supplementary information about a [[Log]] as generated/passed by the
* [[Logger]]. This type should not typically be used directly by users.
*
* @param level
* The selected [[LogLevel]]
* @param timestamp
* The instant the [[Log]] was _submitted_.
* @param owner
* The name of the [[Logger]] which submitted the log.
* @param trace Optional [[LogTrace]] for a specific call.
*/
case class Metadata(
level: LogLevel,
timestamp: Instant,
owner: Logger.Name,
trace: Option[LogTrace]
)
end Log

View file

@ -0,0 +1,238 @@
package gs.log.v0
object LogData:
/** Opaque type (String) that represents a key that identifies some piece of
* data in a [[Log]].
*/
opaque type Key = String
object Key:
/** Instantiate a new [[Key]] from the given input string.
*
* @param value
* The value of the key.
* @return
* The new [[Key]] instance.
*/
def apply(value: String): Key = value
given CanEqual[Key, Key] = CanEqual.derived
extension (key: Key)
/** Express this key as a `String`.
*/
def str(): String = key
end Key
/** Represents some data that can be logged. This is the target of the
* [[Loggable]] type class. Note that `Value` is not typically used directly
* by callers - [[Safe]] is used to ensure that all data is explicitly
* denoted.
*/
sealed trait Value:
type A
/** @return
* 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()
/** Indicates data (in terms of [[Value]]) that has been explicitly marked as
* _safe_ to log in some form.
*/
sealed trait Safe
object Safe:
given CanEqual[Safe, Safe] = CanEqual.derived
end Safe
/** The contained 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.
*/
case class ClearText(value: Value) extends Safe
object ClearText:
given CanEqual[ClearText, ClearText] = CanEqual.derived
end ClearText
/** The contained 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.
*/
case class RequiresEncryption(value: Value) extends Safe
object RequiresEncryption:
given CanEqual[RequiresEncryption, RequiresEncryption] = CanEqual.derived
end RequiresEncryption
/** 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.
*/
case class RequiresSecureHash(value: Value) extends Safe
object RequiresSecureHash:
given CanEqual[RequiresSecureHash, RequiresSecureHash] = CanEqual.derived
end RequiresSecureHash
def logClearText[A: Loggable](data: A): ClearText =
ClearText(Loggable[A].renderForLogs(data))
def logEncrypted[A: Loggable](data: A): RequiresEncryption =
RequiresEncryption(Loggable[A].renderForLogs(data))
def logHashed[A: Loggable](data: A): RequiresSecureHash =
RequiresSecureHash(Loggable[A].renderForLogs(data))
trait Syntax:
extension [A: Loggable](data: A)
/** 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(): ClearText = LogData.logClearText(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(): RequiresEncryption = LogData.logEncrypted(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(): RequiresSecureHash = LogData.logHashed(data)
end Syntax
end LogData

View file

@ -0,0 +1,64 @@
package gs.log.v0
/** Enumerates all supported log levels, which control logging verbosity.
*
* @param name
* The name of the log level.
* @param ordinal
* The order/precedence of the log level (lower level = higher precedence).
*/
sealed abstract class LogLevel(
val name: String,
val ordinal: Int
) extends Ordered[LogLevel]:
/** Compare two [[LogLevel]] instances for the purpose of ordering. More
* important levels are smaller, more verbose levels are larger.
*
* Off < Fatal < Error < Warn < Info < Debug < Trace
*
* @param that
* The [[LogLevel]] to compare against.
* @return
* The comparison result of this level against the other.
*/
override def compare(that: LogLevel): Int = ordinal - that.ordinal
object LogLevel:
/** 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.
*/
case object Trace extends LogLevel("trace", 5)
/** Used to provide additional detail, typically used for debugging behavior.
*/
case object Debug extends LogLevel("debug", 4)
/** Provides a high-level explanation of behavior at runtime. This is the
* default level of detail for logs.
*/
case object Info extends LogLevel("info", 3)
/** Used for cases where something unexpected or "in error" happens, but the
* software is able to continue operating normally.
*/
case object Warn extends LogLevel("warn", 2)
/** Used when the software can no longer operate normally. Things like
* exceptions typically fall into this category. Things like database
* connection failures might be represented with error logs.
*/
case object Error extends LogLevel("error", 1)
/** Typically represents a case where an application is forced to crash due to
* some type of error. For example, a web application might report a fatal
* error if it cannot read required configuration on startup and fail to
* start entirely.
*/
case object Fatal extends LogLevel("fatal", 0)
/** Indicates that logging is completely disabled.
*/
case object Off extends LogLevel("off", -1)
given CanEqual[LogLevel, LogLevel] = CanEqual.derived
end LogLevel

View file

@ -0,0 +1,43 @@
package gs.log.v0
/** Opaque type (String) for representing messages that should be logged. This
* type can _only_ be instantiated using the `log` string interpolator, which
* allows it to be (relatively) "safe" in that users _must_ explicitly declare
* all data they intend to log as safe for clear text.
*
* Example:
* {{{
* val data = "example".logClearText()
* val message: LogMessage = log"this is an example '$data'"
* }}}
*/
opaque type LogMessage = String
object LogMessage:
given CanEqual[LogMessage, LogMessage] = CanEqual.derived
extension (logMessage: LogMessage)
/** Express this [[LogMessage]] as a string.
*
* @return
* The string rendition of this [[LogMessage]].
*/
def str(): String = logMessage
trait Builder:
extension (sc: StringContext)
/** String interpolator for instantiating a [[LogMessage]].
*
* @param args
* All interpolation variables. Must be [[LogData.ClearText]]
* instances.
* @return
* The new [[LogMessage]].
*/
def log(args: LogData.ClearText*): LogMessage =
sc.s(args.map(_.value.render())*)
end LogMessage

View file

@ -0,0 +1,40 @@
package gs.log.v0
/** Default tag definitions. Tags are arbitrary strings attached to logs in the
* `tags` field. They can be used to describe what is being logged.
*
* Users of this library may define their own tags -- tags are just strings.
* These are just common examples with straightforward definitions.
*/
object LogTags:
/** Used to flag any logs that contain PHI (Protected Health Information).
*/
val PHI: String = "phi"
/** Used to flag any logs that contain PII (Personally Identifiable
* Information).
*/
val PII: String = "pii"
/** Used to flag any logs that contain financial information.
*/
val Financial: String = "fin"
/** Used to flag any logs that must be routed to a SIEM. Intended to be used
* as an independent catch-all, and may also be used in conjunction with
* other tags.
*/
val SIEM: String = "siem"
/**
* Used to flag any logs relevant to authentication.
*/
val Authn: String = "authn"
/**
* Used to flag any logs relevant to authorization.
*/
val Authz: String = "authz"
end LogTags

View file

@ -0,0 +1,16 @@
package gs.log.v0
import gs.uuid.v0.UUID
/** Trace information for logger output. Based on OpenTelemetry distributed
* traces.
*
* @param traceId
* The unique identifier of the distributed trace. This is a 128-bit UUID.
* @param spanId
* The unique identifier of the current span. This is a 64-bit value.
*/
case class LogTrace(
traceId: UUID,
spanId: Long
)

View file

@ -0,0 +1,35 @@
package gs.log.v0
import java.time.Instant
import java.time.LocalDate
/** Type class for data that can be logged by the `gs-log` library.
*/
trait Loggable[A]:
def renderForLogs(data: A): LogData.Value
object Loggable:
def apply[A](
using
L: Loggable[A]
): Loggable[A] = L
given Loggable[String] = LogData.Str(_)
given Loggable[Boolean] = LogData.Bool(_)
given Loggable[Int] = LogData.Int32(_)
given Loggable[Long] = LogData.Int64(_)
given Loggable[Float] = LogData.Float32(_)
given Loggable[Double] = LogData.Float64(_)
/** 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

View file

@ -0,0 +1,35 @@
package gs.log.v0
/**
* Interface for emitting logs. This and [[Log]] are the primary types that
* users of this library will interact with.
*/
trait Logger[F[_]]:
def trace(log: => Log): F[Unit]
def debug(log: => Log): F[Unit]
def info(log: => Log): F[Unit]
def warn(log: => Log): F[Unit]
def error(log: => Log): F[Unit]
def fatal(log: => Log): F[Unit]
def name(): Logger.Name
def isLevelEnabled(level: LogLevel): Boolean
object Logger:
/** Opaque type (String) used to represent the descriptive name of a logger.
* If a logger has a name, logs _may_ include that name in their output (if
* configured to do so).
*/
opaque type Name = String
object Name:
def apply(loggerName: String): Name = loggerName
given CanEqual[Name, Name] = CanEqual.derived
extension (name: Name) def str(): String = name
end Name
end Logger

View file

@ -0,0 +1,7 @@
package gs.log.v0
private object LogMessageBuilder extends LogMessage.Builder
private object LogDataSyntax extends LogData.Syntax
export LogDataSyntax.*
export LogMessageBuilder.*

View file

@ -0,0 +1,25 @@
package gs.log.v0
class LogMessageTests extends munit.FunSuite:
test("should instantiate a log message from a string literal") {
val msg = log"1 2 3 4"
assert(msg.str() == "1 2 3 4")
}
test("should support equality checks") {
val msg = log"1 2 3 4"
val alt = log"1 2 3 4"
assert(msg == alt)
assert(msg.str() == alt.str())
assert(msg.toString() == "1 2 3 4")
assert(msg.hashCode() == "1 2 3 4".hashCode())
}
test("should accept variables safe to log in clear text") {
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()}"
assert(msg.str() == expected)
}