diff --git a/README.md b/README.md index dc15a49..a624ece 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This artifact is available in the Garrity Software Maven repository. externalResolvers += "Garrity Software Releases" at "https://maven.garrity.co/gs" -val GsHex: ModuleID = +val GsLog: ModuleID = "gs" %% "gs-log-v0" % "$VERSION" ``` diff --git a/build.sbt b/build.sbt index 0fd5ffe..87e8758 100644 --- a/build.sbt +++ b/build.sbt @@ -1,14 +1,14 @@ 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 / versionScheme := Some("semver-spec") 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( publish := {} ) @@ -32,8 +32,17 @@ lazy val `gs-log` = project .settings(noPublishSettings) .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 .in(file("modules/api")) .settings(sharedSettings) .settings(testSettings) .settings(name := s"${gsProjectName.value}-api-v${semVerMajor.value}") + .settings( + libraryDependencies ++= Seq( + "gs" %% "gs-uuid-v0" % "0.2.2" + ) + ) diff --git a/modules/api/src/main/scala/gs/log/v0/Log.scala b/modules/api/src/main/scala/gs/log/v0/Log.scala new file mode 100644 index 0000000..2422841 --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/Log.scala @@ -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 diff --git a/modules/api/src/main/scala/gs/log/v0/LogData.scala b/modules/api/src/main/scala/gs/log/v0/LogData.scala new file mode 100644 index 0000000..c4f1d22 --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/LogData.scala @@ -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 diff --git a/modules/api/src/main/scala/gs/log/v0/LogLevel.scala b/modules/api/src/main/scala/gs/log/v0/LogLevel.scala new file mode 100644 index 0000000..2c291be --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/LogLevel.scala @@ -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 diff --git a/modules/api/src/main/scala/gs/log/v0/LogMessage.scala b/modules/api/src/main/scala/gs/log/v0/LogMessage.scala new file mode 100644 index 0000000..975a5a4 --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/LogMessage.scala @@ -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 diff --git a/modules/api/src/main/scala/gs/log/v0/LogTags.scala b/modules/api/src/main/scala/gs/log/v0/LogTags.scala new file mode 100644 index 0000000..8168e25 --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/LogTags.scala @@ -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 diff --git a/modules/api/src/main/scala/gs/log/v0/LogTrace.scala b/modules/api/src/main/scala/gs/log/v0/LogTrace.scala new file mode 100644 index 0000000..5ef95e3 --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/LogTrace.scala @@ -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 +) diff --git a/modules/api/src/main/scala/gs/log/v0/Loggable.scala b/modules/api/src/main/scala/gs/log/v0/Loggable.scala new file mode 100644 index 0000000..5bfd735 --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/Loggable.scala @@ -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 diff --git a/modules/api/src/main/scala/gs/log/v0/Logger.scala b/modules/api/src/main/scala/gs/log/v0/Logger.scala new file mode 100644 index 0000000..5dface0 --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/Logger.scala @@ -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 diff --git a/modules/api/src/main/scala/gs/log/v0/package.scala b/modules/api/src/main/scala/gs/log/v0/package.scala new file mode 100644 index 0000000..6e718f0 --- /dev/null +++ b/modules/api/src/main/scala/gs/log/v0/package.scala @@ -0,0 +1,7 @@ +package gs.log.v0 + +private object LogMessageBuilder extends LogMessage.Builder +private object LogDataSyntax extends LogData.Syntax + +export LogDataSyntax.* +export LogMessageBuilder.* diff --git a/modules/api/src/test/scala/gs/log/v0/LogMessageTests.scala b/modules/api/src/test/scala/gs/log/v0/LogMessageTests.scala new file mode 100644 index 0000000..bd04703 --- /dev/null +++ b/modules/api/src/test/scala/gs/log/v0/LogMessageTests.scala @@ -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) + }