Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Json appender #458

Merged
merged 7 commits into from
Jun 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions core/jvm/src/test/scala/zio/logging/JsonLogFormatSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package zio.logging

import zio.logging.LogFormat.{ line, _ }
import zio.logging.internal.JsonEscape
import zio.test._
import zio.{ Cause, FiberId, FiberRefs, LogLevel, Trace }

object JsonLogFormatSpec extends ZIOSpecDefault {
private val nonEmptyString = Gen.stringBounded(1, 5)(Gen.alphaNumericChar)

val spec: Spec[Environment, Any] = suite("JsonLogFormatSpec")(
test("line") {
val format = line
check(nonEmptyString) { line =>
val result = format
.toJsonLogger(
Trace.empty,
FiberId.None,
LogLevel.Info,
() => line,
Cause.empty,
FiberRefs.empty,
Nil,
Map.empty
)
assertTrue(result == s"""{"text_content":"${JsonEscape(line)}"}""")
}
},
test("annotation") {
val format = annotation("test")
check(Gen.string) { annotationValue =>
val result = format.toJsonLogger(
Trace.empty,
FiberId.None,
LogLevel.Info,
() => "",
Cause.empty,
FiberRefs.empty,
Nil,
Map("test" -> annotationValue)
)
assertTrue(result == s"""{"test":"${JsonEscape(annotationValue)}"}""")
}
},
test("annotation (structured)") {
val format = annotation(LogAnnotation.UserId)
check(Gen.string) { annotationValue =>
val result = format.toJsonLogger(
Trace.empty,
FiberId.None,
LogLevel.Info,
() => "",
Cause.empty,
FiberRefs.empty.updatedAs(FiberId.Runtime(0, 0, Trace.empty))(
logContext,
LogContext.empty.annotate(LogAnnotation.UserId, annotationValue)
),
Nil,
Map.empty
)
assertTrue(result == s"""{"user_id":"${JsonEscape(annotationValue)}"}""")
}
},
test("empty annotation") {
val format = annotation("test")
val result = format.toJsonLogger(
Trace.empty,
FiberId.None,
LogLevel.Info,
() => "",
Cause.empty,
FiberRefs.empty,
Nil,
Map.empty
)
assertTrue(result == "{}")
},
test("several labels") {
val format = label("msg", line) + label("fiber", fiberId)
check(Gen.string, Gen.int) { (line, fiberId) =>
val result = format.toJsonLogger(
Trace.empty,
FiberId(fiberId, 1, Trace.empty),
LogLevel.Info,
() => line,
Cause.empty,
FiberRefs.empty,
Nil,
Map.empty
)
val msg = JsonEscape(line)
val fiber = s"zio-fiber-${JsonEscape(fiberId.toString)}"
assertTrue(result == s"""{"msg":"$msg","fiber":"$fiber"}""")
}
},
test("nested labels") {
val format = label("msg", line) + label("nested", label("fiber", fiberId) + annotation("test"))
check(Gen.alphaNumericString, Gen.int, nonEmptyString) { (line, fiberId, annotationValue) =>
val result = format.toJsonLogger(
Trace.empty,
FiberId(fiberId, 1, Trace.empty),
LogLevel.Info,
() => line,
Cause.empty,
FiberRefs.empty,
Nil,
Map("test" -> annotationValue)
)
val msg = JsonEscape(line)
val fiber = s"zio-fiber-${JsonEscape(fiberId.toString)}"
val ann = JsonEscape(annotationValue)
assertTrue(result == s"""{"msg":"$msg","nested":{"fiber":"$fiber","test":"$ann"}}""")
}
},
test("mixed structured / unstructured ") {
val format =
label("msg", line) + text("hi") +
label(
"nested",
label("fiber", fiberId |-| text("abc") + label("third", text("3"))) + annotation("test")
) + text(" there")
check(Gen.alphaNumericString, Gen.int, nonEmptyString) { (line, fiberId, annotationValue) =>
val result = format.toJsonLogger(
Trace.empty,
FiberId(fiberId, 1, Trace.empty),
LogLevel.Info,
() => line,
Cause.empty,
FiberRefs.empty,
Nil,
Map("test" -> annotationValue)
)
val msg = JsonEscape(line)
val fiber = s"zio-fiber-${JsonEscape(fiberId.toString)}"
val ann = JsonEscape(annotationValue)
assertTrue(
result == s"""{"text_content":"hi there","msg":"$msg","nested":{"fiber":{"text_content":"$fiber abc","third":"3"},"test":"$ann"}}"""
)
}
}
)
}
25 changes: 25 additions & 0 deletions core/shared/src/main/scala/internal/JsonEscape.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package zio.logging.internal

import scala.collection.mutable

object JsonEscape {
def apply(s: String): String =
escape(s, new mutable.StringBuilder()).toString()

private def escape(s: String, sb: mutable.StringBuilder): mutable.StringBuilder = {
if (s != null || s.isEmpty)
for { c <- s } c match {
case '\\' => sb.append('\\').append(c)
case '"' => sb.append('\\').append(c)
case '/' => sb.append('\\').append(c)
case '\b' => sb.append("\\b")
case '\t' => sb.append("\\t")
case '\n' => sb.append("\\n")
case '\f' => sb.append("\\f")
case '\r' => sb.append("\\r")
case ctrl if ctrl < ' ' => sb.append("\\u").append("000").append(Integer.toHexString(ctrl.toInt))
case _ => sb.append(c)
}
sb
}
}
110 changes: 110 additions & 0 deletions core/shared/src/main/scala/internal/LogAppender.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ package zio.logging.internal

import zio._

import scala.collection.mutable

/**
* A [[LogAppender]] is a low-level interface designed to be the bridge between
* ZIO Logging and logging backends, such as Logback. This interface is slightly
Expand Down Expand Up @@ -62,6 +64,11 @@ private[logging] trait LogAppender { self =>
*/
def closeKeyOpenValue(): Unit

/**
* Marks the close of the log entry
*/
def closeLogEntry(): Unit

/**
* Marks the close of the value of a key/value pair.
*/
Expand All @@ -72,6 +79,11 @@ private[logging] trait LogAppender { self =>
*/
def openKey(): Unit

/**
* Marks the start of the log entry
*/
def openLogEntry(): Unit

/**
* Modifies the way text is appended to the log.
*/
Expand All @@ -91,9 +103,13 @@ private[logging] object LogAppender {

def closeKeyOpenValue(): Unit = self.closeKeyOpenValue()

def closeLogEntry(): Unit = self.closeLogEntry()

def closeValue(): Unit = self.closeValue()

def openKey(): Unit = self.openKey()

def openLogEntry(): Unit = self.openLogEntry()
}

/**
Expand All @@ -109,8 +125,102 @@ private[logging] object LogAppender {

def closeKeyOpenValue(): Unit = appendText("=")

def closeLogEntry(): Unit = ()

def closeValue(): Unit = ()

def openKey(): Unit = ()

def openLogEntry(): Unit = ()
}

def json(textAppender: String => Any): LogAppender = new LogAppender { self =>
class State(
var root: Boolean = false,
var separateKeyValue: Boolean = false,
var writingKey: Boolean = false,
val content: mutable.StringBuilder = new mutable.StringBuilder,
var textContent: mutable.StringBuilder = new mutable.StringBuilder
) {
def appendContent(str: CharSequence): Unit = { content.append(str); () }
def appendTextContent(str: CharSequence): Unit = { textContent.append(str); () }
}

val stack = new mutable.Stack[State]()

def current: State = stack.top

override def appendCause(cause: Cause[Any]): Unit = appendText(cause.prettyPrint)

override def appendNumeric[A](numeric: A): Unit = appendText(numeric.toString)

override def appendText(text: String): Unit =
if (current.writingKey) current.appendContent(text)
else current.appendTextContent(text)

def beginStructure(root: Boolean = false): Unit = { stack.push(new State(root = root)); () }

def endStructure(): mutable.StringBuilder = {
val result = new mutable.StringBuilder

val cleanedTextContent = {
// Do a little cleanup to handle default log formats (quoted and spaced)
if (current.textContent.startsWith("\"") && current.textContent.endsWith("\""))
current.textContent = current.textContent.drop(1).dropRight(1)
if (current.textContent.forall(_ == ' ')) current.textContent.clear()
current.textContent.toString()
}

if (current.content.isEmpty && !current.root) {
// Simple value
result.append("\"").append(JsonEscape(cleanedTextContent)).append("\"")
} else {
// Structure
result.append("{")

if (current.textContent.nonEmpty) {
result.append(""""text_content":""")
result.append("\"").append(JsonEscape(cleanedTextContent)).append("\"")
}

if (current.content.nonEmpty) {
if (current.textContent.nonEmpty) result.append(",")
result.append(current.content)
}

result.append("}")
}

stack.pop()
result
}

override def closeKeyOpenValue(): Unit = {
current.writingKey = false
current.appendContent("""":""")
beginStructure()
}

override def closeLogEntry(): Unit = {
textAppender(endStructure().toString())
()
}

override def closeValue(): Unit = {
val result = endStructure()
current.appendContent(result)
}

override def openKey(): Unit = {
if (current.separateKeyValue) current.appendContent(",")
current.separateKeyValue = true
current.writingKey = true
current.appendContent("\"")
}

override def openLogEntry(): Unit = {
stack.clear()
beginStructure(true)
}
}
}
35 changes: 35 additions & 0 deletions core/shared/src/main/scala/zio/logging/LogFormat.scala
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,41 @@ trait LogFormat { self =>
final def spaced(other: LogFormat): LogFormat =
this |-| other

/**
* Converts this log format into a json logger, which accepts text input, and
* produces json output.
*/
final def toJsonLogger: ZLogger[String, String] = (
trace: Trace,
fiberId: FiberId,
logLevel: LogLevel,
message: () => String,
cause: Cause[Any],
context: FiberRefs,
spans: List[LogSpan],
annotations: Map[String, String]
) => {
val logEntryFormat =
LogFormat.make { (builder, trace, fiberId, level, line, fiberRefs, cause, spans, annotations) =>
builder.openLogEntry()
try self.unsafeFormat(builder)(trace, fiberId, level, line, fiberRefs, cause, spans, annotations)
finally builder.closeLogEntry()
}

val builder = new StringBuilder()
logEntryFormat.unsafeFormat(LogAppender.json(builder.append(_)))(
trace,
fiberId,
logLevel,
message,
cause,
context,
spans,
annotations
)
builder.toString()
}

/**
* Converts this log format into a text logger, which accepts text input, and
* produces text output.
Expand Down
Loading