Compare commits
5 commits
Author | SHA1 | Date | |
---|---|---|---|
6b3d848bab | |||
e3c0a4260b | |||
c1e7e594e8 | |||
6999f02da0 | |||
7917cdf4dc |
16 changed files with 955 additions and 12 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
54
build.sbt
54
build.sbt
|
@ -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 := {}
|
||||||
)
|
)
|
||||||
|
@ -20,20 +20,60 @@ val sharedSettings = Seq(
|
||||||
/* coverageMinimumStmtTotal := 100, coverageMinimumBranchTotal := 100 */
|
/* coverageMinimumStmtTotal := 100, coverageMinimumBranchTotal := 100 */
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val Deps = new {
|
||||||
|
val Cats = new {
|
||||||
|
val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.10.0"
|
||||||
|
val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.5.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
val Fs2 = new {
|
||||||
|
val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.10.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
val Gs = new {
|
||||||
|
val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.2.3"
|
||||||
|
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.1.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.0.0-M12"
|
||||||
|
}
|
||||||
|
|
||||||
lazy val testSettings = Seq(
|
lazy val testSettings = Seq(
|
||||||
libraryDependencies ++= Seq(
|
libraryDependencies ++= Seq(
|
||||||
"org.scalameta" %% "munit" % "1.0.0-M10" % Test
|
Deps.MUnit % Test,
|
||||||
|
Deps.Gs.Datagen % Test
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
lazy val `gs-log` = project
|
lazy val `gs-log` = project
|
||||||
.in(file("."))
|
.in(file("."))
|
||||||
.aggregate(api)
|
.aggregate(data, api, engine)
|
||||||
.settings(noPublishSettings)
|
.settings(noPublishSettings)
|
||||||
.settings(name := s"${gsProjectName.value}-v${semVerMajor.value}")
|
.settings(name := s"${gsProjectName.value}-v${semVerMajor.value}")
|
||||||
|
|
||||||
|
lazy val data = project
|
||||||
|
.in(file("modules/data"))
|
||||||
|
.settings(sharedSettings)
|
||||||
|
.settings(testSettings)
|
||||||
|
.settings(name := s"${gsProjectName.value}-data-v${semVerMajor.value}")
|
||||||
|
.settings(libraryDependencies ++= Seq(
|
||||||
|
Deps.Cats.Core
|
||||||
|
))
|
||||||
|
|
||||||
lazy val api = project
|
lazy val api = project
|
||||||
.in(file("modules/api"))
|
.in(file("modules/api"))
|
||||||
|
.dependsOn(data)
|
||||||
.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}")
|
||||||
|
|
||||||
|
lazy val engine = project
|
||||||
|
.in(file("modules/engine"))
|
||||||
|
.dependsOn(data, api)
|
||||||
|
.settings(sharedSettings)
|
||||||
|
.settings(testSettings)
|
||||||
|
.settings(name := s"${gsProjectName.value}-engine-v${semVerMajor.value}")
|
||||||
|
.settings(libraryDependencies ++= Seq(
|
||||||
|
Deps.Cats.Effect,
|
||||||
|
Deps.Fs2.Core
|
||||||
|
))
|
||||||
|
|
99
modules/api/src/main/scala/gs/log/v0/api/Logger.scala
Normal file
99
modules/api/src/main/scala/gs/log/v0/api/Logger.scala
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
package gs.log.v0.api
|
||||||
|
|
||||||
|
import gs.log.v0.data.Log
|
||||||
|
import gs.log.v0.data.LogLevel
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for emitting logs. This and [[Log]] are the primary types that
|
||||||
|
* 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): F[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:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
||||||
|
given Show[Name] = new Show[Name] {
|
||||||
|
override def show(t: Name): String = t
|
||||||
|
}
|
||||||
|
|
||||||
|
end Name
|
||||||
|
|
||||||
|
end Logger
|
158
modules/data/src/main/scala/gs/log/v0/data/Log.scala
Normal file
158
modules/data/src/main/scala/gs/log/v0/data/Log.scala
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
package gs.log.v0.data
|
||||||
|
|
||||||
|
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
|
||||||
|
):
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
||||||
|
def getData: Map[LogData.Key, LogData.Safe] = internalData.toMap
|
||||||
|
|
||||||
|
def getTags: List[String] = internalTags
|
||||||
|
|
||||||
|
def getMessage: Option[LogMessage] = internalMessage
|
||||||
|
|
||||||
|
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 logMessage The message.
|
||||||
|
* @return New [[Log]] instance.
|
||||||
|
*/
|
||||||
|
def message(logMessage: LogMessage): Log =
|
||||||
|
new Log().message(logMessage)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
*/
|
||||||
|
case class Metadata(
|
||||||
|
level: LogLevel,
|
||||||
|
timestamp: Instant,
|
||||||
|
owner: String
|
||||||
|
)
|
||||||
|
|
||||||
|
end Log
|
341
modules/data/src/main/scala/gs/log/v0/data/LogData.scala
Normal file
341
modules/data/src/main/scala/gs/log/v0/data/LogData.scala
Normal file
|
@ -0,0 +1,341 @@
|
||||||
|
package gs.log.v0.data
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
trait Value:
|
||||||
|
type A
|
||||||
|
|
||||||
|
/** @return
|
||||||
|
* The data to log.
|
||||||
|
*/
|
||||||
|
def data: A
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide standard capture support for strings.
|
||||||
|
*
|
||||||
|
* @param data The string data.
|
||||||
|
*/
|
||||||
|
case class Str(data: String) extends Value:
|
||||||
|
override type A = String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide standard capture support for Boolean values.
|
||||||
|
*
|
||||||
|
* @param data The Boolean data.
|
||||||
|
*/
|
||||||
|
case class Bool(data: Boolean) extends Value:
|
||||||
|
override type A = Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide standard capture support for 32-bit integers.
|
||||||
|
*
|
||||||
|
* @param data The integer data.
|
||||||
|
*/
|
||||||
|
case class Int32(data: Int) extends Value:
|
||||||
|
override type A = Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide standard capture support for 64-bit integers.
|
||||||
|
*
|
||||||
|
* @param data The long integer data.
|
||||||
|
*/
|
||||||
|
case class Int64(data: Long) extends Value:
|
||||||
|
override type A = Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide standard capture support for 32-bit floating point numbers.
|
||||||
|
*
|
||||||
|
* @param data The floating point data.
|
||||||
|
*/
|
||||||
|
case class Float32(data: Float) extends Value:
|
||||||
|
override type A = Float
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide standard capture support for 64-bit floating point numbers.
|
||||||
|
*
|
||||||
|
* @param data The double-precision floating point data.
|
||||||
|
*/
|
||||||
|
case class Float64(data: Double) extends Value:
|
||||||
|
override type A = Double
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide standard capture support for dates.
|
||||||
|
*
|
||||||
|
* @param data The date data.
|
||||||
|
*/
|
||||||
|
case class Date(data: LocalDate) extends Value:
|
||||||
|
override type A = LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide standard capture support for instants.
|
||||||
|
*
|
||||||
|
* @param data The instant data.
|
||||||
|
*/
|
||||||
|
case class Instant(data: java.time.Instant) extends Value:
|
||||||
|
override type A = java.time.Instant
|
||||||
|
|
||||||
|
trait Generic[AA, Codec[_]] extends Value:
|
||||||
|
override type A = AA
|
||||||
|
def codec: Codec[A]
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
/** 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].capture(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].capture(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].capture(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
|
64
modules/data/src/main/scala/gs/log/v0/data/LogLevel.scala
Normal file
64
modules/data/src/main/scala/gs/log/v0/data/LogLevel.scala
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
package gs.log.v0.data
|
||||||
|
|
||||||
|
/** 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:
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
end LogLevel
|
76
modules/data/src/main/scala/gs/log/v0/data/LogMessage.scala
Normal file
76
modules/data/src/main/scala/gs/log/v0/data/LogMessage.scala
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
package gs.log.v0.data
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
given Show[LogMessage] = new Show[LogMessage] {
|
||||||
|
override def show(t: LogMessage): String = t
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a string that is explicitly safe to show in clear text.
|
||||||
|
*
|
||||||
|
* @param data The clear text data.
|
||||||
|
*/
|
||||||
|
case class ClearTextString(data: String)
|
||||||
|
|
||||||
|
object ClearTextString:
|
||||||
|
|
||||||
|
given CanEqual[ClearTextString, ClearTextString] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[ClearTextString] = new Show[ClearTextString] {
|
||||||
|
override def show(t: ClearTextString): String = t.data
|
||||||
|
}
|
||||||
|
|
||||||
|
end ClearTextString
|
||||||
|
|
||||||
|
extension (logMessage: LogMessage)
|
||||||
|
/** Express this [[LogMessage]] as a string.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The string rendition of this [[LogMessage]].
|
||||||
|
*/
|
||||||
|
def unwrap(): String = logMessage
|
||||||
|
|
||||||
|
trait Syntax:
|
||||||
|
|
||||||
|
extension [A: Show](data: A)
|
||||||
|
/**
|
||||||
|
* Explicitly mark this data as _safe_ to render in clear text. Produce a
|
||||||
|
* string representation.
|
||||||
|
*
|
||||||
|
* @return The clear text string representation of this data.
|
||||||
|
*/
|
||||||
|
def clearTextString(): ClearTextString =
|
||||||
|
ClearTextString(Show[A].show(data))
|
||||||
|
|
||||||
|
extension (sc: StringContext)
|
||||||
|
|
||||||
|
/** String interpolator for instantiating a [[LogMessage]].
|
||||||
|
*
|
||||||
|
* @param args
|
||||||
|
* All interpolation variables. Must be [[LogMessage.ClearTextString]]
|
||||||
|
* instances.
|
||||||
|
* @return
|
||||||
|
* The new [[LogMessage]].
|
||||||
|
*/
|
||||||
|
def log(args: ClearTextString*): LogMessage =
|
||||||
|
sc.s(args.map(cts => cts.data)*)
|
||||||
|
|
||||||
|
end LogMessage
|
40
modules/data/src/main/scala/gs/log/v0/data/LogTags.scala
Normal file
40
modules/data/src/main/scala/gs/log/v0/data/LogTags.scala
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
package gs.log.v0.data
|
||||||
|
|
||||||
|
/** 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
|
34
modules/data/src/main/scala/gs/log/v0/data/Loggable.scala
Normal file
34
modules/data/src/main/scala/gs/log/v0/data/Loggable.scala
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
package gs.log.v0.data
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/** Type class for data that can be logged by the `gs-log` library.
|
||||||
|
*/
|
||||||
|
trait Loggable[A]:
|
||||||
|
/**
|
||||||
|
* Capture the given data as some primitive supported by `gs-log` for
|
||||||
|
* propagating data for future rendition.
|
||||||
|
*
|
||||||
|
* @param data The data to capture.
|
||||||
|
* @return The [[LogData.Value]] housing the output primitive.
|
||||||
|
*/
|
||||||
|
def capture(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(_)
|
||||||
|
given Loggable[LocalDate] = LogData.Date(_)
|
||||||
|
given Loggable[Instant] = LogData.Instant(_)
|
||||||
|
|
||||||
|
end Loggable
|
10
modules/data/src/main/scala/gs/log/v0/syntax/package.scala
Normal file
10
modules/data/src/main/scala/gs/log/v0/syntax/package.scala
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
package gs.log.v0.syntax
|
||||||
|
|
||||||
|
import gs.log.v0.data.LogMessage
|
||||||
|
import gs.log.v0.data.LogData
|
||||||
|
|
||||||
|
private object LogMessageSyntax extends LogMessage.Syntax
|
||||||
|
private object LogDataSyntax extends LogData.Syntax
|
||||||
|
|
||||||
|
export LogDataSyntax.*
|
||||||
|
export LogMessageSyntax.*
|
|
@ -0,0 +1,28 @@
|
||||||
|
package gs.log.v0.data
|
||||||
|
|
||||||
|
import gs.log.v0.syntax.*
|
||||||
|
import cats.syntax.all.*
|
||||||
|
|
||||||
|
class LogMessageTests extends munit.FunSuite:
|
||||||
|
|
||||||
|
test("should instantiate a log message from a string literal") {
|
||||||
|
val msg = log"1 2 3 4"
|
||||||
|
assertEquals(msg.show, "1 2 3 4")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should support equality checks") {
|
||||||
|
val msg = log"1 2 3 4"
|
||||||
|
val alt = log"1 2 3 4"
|
||||||
|
assertEquals(msg, alt)
|
||||||
|
assertEquals(msg.show, alt.show)
|
||||||
|
assertEquals(msg.toString(), "1 2 3 4")
|
||||||
|
assertEquals(msg.hashCode(), "1 2 3 4".hashCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should accept variables safe to log in clear text") {
|
||||||
|
val x = "foo".clearTextString()
|
||||||
|
val y = 10.clearTextString()
|
||||||
|
val msg: LogMessage = log"x = $x, y = $y"
|
||||||
|
val expected = s"x = ${x.data}, y = ${y.data}"
|
||||||
|
assertEquals(msg.show, expected)
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
package gs.log.v0.engine
|
||||||
|
|
||||||
|
import gs.log.v0.api.Logger
|
||||||
|
import cats.effect.Async
|
||||||
|
import gs.log.v0.data.Log
|
||||||
|
import gs.log.v0.data.LogLevel
|
||||||
|
import gs.log.v0.api.Logger.Name
|
||||||
|
import cats.effect.Ref
|
||||||
|
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import cats.effect.std.Queue
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
final class QueueLogger[F[_]: Async](
|
||||||
|
val name: Logger.Name,
|
||||||
|
private val clock: Clock,
|
||||||
|
private val maxLevel: Ref[F, LogLevel],
|
||||||
|
private val queue: Queue[F, (Log, Log.Metadata)]
|
||||||
|
) extends Logger[F] {
|
||||||
|
|
||||||
|
override def isLevelEnabled(level: LogLevel): F[Boolean] =
|
||||||
|
maxLevel.get.map(max => level <= max)
|
||||||
|
|
||||||
|
override def warn(log: => Log): F[Unit] = ???
|
||||||
|
|
||||||
|
override def debug(log: => Log): F[Unit] = ???
|
||||||
|
|
||||||
|
override def info(log: => Log): F[Unit] = ???
|
||||||
|
|
||||||
|
override def error(log: => Log): F[Unit] = ???
|
||||||
|
|
||||||
|
override def fatal(log: => Log): F[Unit] = ???
|
||||||
|
|
||||||
|
override def trace(log: => Log): F[Unit] =
|
||||||
|
meta(LogLevel.Trace).flatMap(m => queue.tryOffer((log, m))).flatMap {
|
||||||
|
case true => Async[F].unit
|
||||||
|
case false => Async[F].unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private def now(): F[Instant] =
|
||||||
|
Async[F].delay(Instant.now(clock))
|
||||||
|
|
||||||
|
private def meta(level: LogLevel): F[Log.Metadata] =
|
||||||
|
now().map(timestamp =>
|
||||||
|
Log.Metadata(
|
||||||
|
level = level,
|
||||||
|
timestamp = timestamp,
|
||||||
|
owner = name.show
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
|
@ -1 +1 @@
|
||||||
sbt.version=1.9.9
|
sbt.version=1.10.0
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Add table
Reference in a new issue