diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0b3779e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true diff --git a/src/main/kotlin/io/github/orangain/prettyjsonlog/Extract.kt b/src/main/kotlin/io/github/orangain/prettyjsonlog/Extract.kt deleted file mode 100644 index 2832713..0000000 --- a/src/main/kotlin/io/github/orangain/prettyjsonlog/Extract.kt +++ /dev/null @@ -1,122 +0,0 @@ -package io.github.orangain.prettyjsonlog - -import com.fasterxml.jackson.databind.JsonNode -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException - -private val timestampKeys = listOf("timestamp", "time", "@timestamp") - -sealed interface Timestamp { - fun format(zoneId: ZoneId, formatter: DateTimeFormatter): String - - data class Parsed(val value: Instant) : Timestamp { - override fun format(zoneId: ZoneId, formatter: DateTimeFormatter): String { - return value.atZone(zoneId).format(formatter) - } - } - - data class Fallback(val value: String) : Timestamp { - override fun format(zoneId: ZoneId, formatter: DateTimeFormatter): String { - return value - } - } - - companion object { - fun fromEpochMilli(value: Long): Parsed { - return Parsed(Instant.ofEpochMilli(value)) - } - - fun fromString(value: String): Timestamp { - return try { - // Use OffsetDateTime.parse instead of Instant.parse because Instant.parse in JDK <= 11 does not support non-UTC offset like "-05:00". - // See: https://stackoverflow.com/questions/68217689/how-to-use-instant-java-class-to-parse-a-date-time-with-offset-from-utc/68221614#68221614 - Parsed(OffsetDateTime.parse(value).toInstant()) - } catch (e: DateTimeParseException) { - Fallback(value) - } - } - } -} - -enum class Level { - TRACE, DEBUG, INFO, WARN, ERROR, FATAL; - - companion object { - fun fromInt(level: Int): Level { - // Use bunyan's level as a reference. - // See: https://github.com/trentm/node-bunyan?tab=readme-ov-file#levels - return when { - level < 20 -> TRACE - level < 30 -> DEBUG - level < 40 -> INFO - level < 50 -> WARN - level < 60 -> ERROR - else -> FATAL - } - } - - fun fromString(level: String): Level? { - // Bunyan's levels: TRACE, DEBUG, INFO, WARN, ERROR, FATAL - // https://github.com/trentm/node-bunyan?tab=readme-ov-file#levels - // Cloud Logging's levels: DEFAULT, DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY - // https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity - // java.util.logging's levels: FINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVERE - // https://docs.oracle.com/en/java/javase/21/docs/api/java.logging/java/util/logging/Level.html - return when (level.uppercase()) { - "TRACE", "FINEST", "FINER", "FINE" -> TRACE - "DEBUG", "CONFIG" -> DEBUG - "INFO", "NOTICE" -> INFO - "WARN", "WARNING" -> WARN - "ERROR", "CRITICAL", "SEVERE" -> ERROR - "FATAL", "ALERT", "EMERGENCY" -> FATAL - else -> null // This includes "DEFAULT" - } - } - } -} - -fun extractTimestamp(node: JsonNode): Timestamp? { - - return timestampKeys.firstNotNullOfOrNull { node.get(it) }?.let { node -> - if (node.isNumber) { - // We assume that the number is a Unix timestamp in milliseconds. - Timestamp.fromEpochMilli(node.asLong()) - } else { - Timestamp.fromString(node.asText()) - } - } -} - -private val levelKeys = listOf("level", "severity", "log.level") - -fun extractLevel(node: JsonNode): Level? { - return levelKeys.firstNotNullOfOrNull { node.get(it) }?.let { node -> - if (node.isNumber) { - Level.fromInt(node.asInt()) - } else { - Level.fromString(node.asText()) - } - } -} - -private val messageKeys = listOf("message", "msg", "error.message") - -fun extractMessage(node: JsonNode): String? { - return messageKeys.firstNotNullOfOrNull { node.get(it) }?.asText() -} - -typealias NodeExtractor = (JsonNode) -> JsonNode? - -private val stackTraceNodeExtractors: List = listOf( - { it.get("stack_trace") }, - { it.get("exception") }, - { it.get("error.stack_trace") }, - { it.get("err")?.get("stack") }, -) - -fun extractStackTrace(node: JsonNode): String? { - return stackTraceNodeExtractors.firstNotNullOfOrNull { it(node) }?.asText() -} diff --git a/src/main/kotlin/io/github/orangain/prettyjsonlog/console/MyConsoleInputFilterProvider.kt b/src/main/kotlin/io/github/orangain/prettyjsonlog/console/MyConsoleInputFilterProvider.kt index 3c7d394..0932416 100644 --- a/src/main/kotlin/io/github/orangain/prettyjsonlog/console/MyConsoleInputFilterProvider.kt +++ b/src/main/kotlin/io/github/orangain/prettyjsonlog/console/MyConsoleInputFilterProvider.kt @@ -1,15 +1,14 @@ package io.github.orangain.prettyjsonlog.console -import com.fasterxml.jackson.databind.JsonNode import com.intellij.execution.filters.ConsoleInputFilterProvider import com.intellij.execution.filters.InputFilter import com.intellij.execution.ui.ConsoleViewContentType import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.project.Project import com.intellij.openapi.util.Pair -import io.github.orangain.prettyjsonlog.* import io.github.orangain.prettyjsonlog.json.parseJson import io.github.orangain.prettyjsonlog.json.prettyPrintJson +import io.github.orangain.prettyjsonlog.logentry.* import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -32,30 +31,23 @@ class MyConsoleInputFilter : InputFilter { val timestamp = extractTimestamp(node) val level = extractLevel(node) - val contentTypeOfLevel = contentTypeOf(level, contentType) val message = extractMessage(node) - val stackTracePair = extractStackTracePair(node, contentTypeOfLevel) + val stackTrace = extractStackTrace(node) + // .trimEnd('\n') is necessary because of the following reasons: + // - When stackTrace is null or empty, we don't want to add an extra newline. + // - When stackTrace ends with a newline, trimming the last newline makes a folding marker look better. + val coloredMessage = "$level: $message\n${stackTrace ?: ""}".trimEnd('\n') val jsonString = prettyPrintJson(node) return mutableListOf( Pair("[${timestamp?.format(zoneId, timestampFormatter)}] ", contentType), - Pair("$level: $message", contentTypeOfLevel), - stackTracePair, + Pair(coloredMessage, contentTypeOf(level, contentType)), Pair( - " \n$jsonString$suffixWhitespaces", + " \n$jsonString$suffixWhitespaces", // Adding a space at the end of line makes a folding marker look better. contentType - ), // Add a space to at the end of line to make it look good when folded. + ), ) } - - private fun extractStackTracePair(node: JsonNode, contentTypeOfLevel: ConsoleViewContentType): Pair { - val stackTrace = extractStackTrace(node) - - if (stackTrace?.isNotEmpty() == true) { - return Pair("\n$stackTrace", contentTypeOfLevel) - } - return Pair("", contentTypeOfLevel) - } } private fun contentTypeOf(level: Level?, inputContentType: ConsoleViewContentType): ConsoleViewContentType { diff --git a/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/Level.kt b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/Level.kt new file mode 100644 index 0000000..e57a1eb --- /dev/null +++ b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/Level.kt @@ -0,0 +1,52 @@ +package io.github.orangain.prettyjsonlog.logentry + +import com.fasterxml.jackson.databind.JsonNode + +enum class Level { + TRACE, DEBUG, INFO, WARN, ERROR, FATAL; + + companion object { + fun fromInt(level: Int): Level { + // Use bunyan's level as a reference. + // See: https://github.com/trentm/node-bunyan?tab=readme-ov-file#levels + return when { + level < 20 -> TRACE + level < 30 -> DEBUG + level < 40 -> INFO + level < 50 -> WARN + level < 60 -> ERROR + else -> FATAL + } + } + + fun fromString(level: String): Level? { + // Bunyan's levels: TRACE, DEBUG, INFO, WARN, ERROR, FATAL + // https://github.com/trentm/node-bunyan?tab=readme-ov-file#levels + // Cloud Logging's levels: DEFAULT, DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY + // https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity + // java.util.logging's levels: FINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVERE + // https://docs.oracle.com/en/java/javase/21/docs/api/java.logging/java/util/logging/Level.html + return when (level.uppercase()) { + "TRACE", "FINEST", "FINER", "FINE" -> TRACE + "DEBUG", "CONFIG" -> DEBUG + "INFO", "NOTICE" -> INFO + "WARN", "WARNING" -> WARN + "ERROR", "CRITICAL", "SEVERE" -> ERROR + "FATAL", "ALERT", "EMERGENCY" -> FATAL + else -> null // This includes "DEFAULT" + } + } + } +} + +private val levelKeys = listOf("level", "severity", "log.level") + +fun extractLevel(node: JsonNode): Level? { + return levelKeys.firstNotNullOfOrNull { node.get(it) }?.let { levelNode -> + if (levelNode.isNumber) { + Level.fromInt(levelNode.asInt()) + } else { + Level.fromString(levelNode.asText()) + } + } +} diff --git a/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/Message.kt b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/Message.kt new file mode 100644 index 0000000..210acdd --- /dev/null +++ b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/Message.kt @@ -0,0 +1,9 @@ +package io.github.orangain.prettyjsonlog.logentry + +import com.fasterxml.jackson.databind.JsonNode + +private val messageKeys = listOf("message", "msg", "error.message") + +fun extractMessage(node: JsonNode): String? { + return messageKeys.firstNotNullOfOrNull { node.get(it) }?.asText() +} diff --git a/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/NodeExtractor.kt b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/NodeExtractor.kt new file mode 100644 index 0000000..c4caa54 --- /dev/null +++ b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/NodeExtractor.kt @@ -0,0 +1,5 @@ +package io.github.orangain.prettyjsonlog.logentry + +import com.fasterxml.jackson.databind.JsonNode + +typealias NodeExtractor = (JsonNode) -> JsonNode? diff --git a/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/StackTrace.kt b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/StackTrace.kt new file mode 100644 index 0000000..3904c4c --- /dev/null +++ b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/StackTrace.kt @@ -0,0 +1,14 @@ +package io.github.orangain.prettyjsonlog.logentry + +import com.fasterxml.jackson.databind.JsonNode + +private val stackTraceNodeExtractors: List = listOf( + { it.get("stack_trace") }, + { it.get("exception") }, + { it.get("error.stack_trace") }, + { it.get("err")?.get("stack") }, +) + +fun extractStackTrace(node: JsonNode): String? { + return stackTraceNodeExtractors.firstNotNullOfOrNull { it(node) }?.asText() +} diff --git a/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/Timestamp.kt b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/Timestamp.kt new file mode 100644 index 0000000..1bb1422 --- /dev/null +++ b/src/main/kotlin/io/github/orangain/prettyjsonlog/logentry/Timestamp.kt @@ -0,0 +1,54 @@ +package io.github.orangain.prettyjsonlog.logentry + +import com.fasterxml.jackson.databind.JsonNode +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +sealed interface Timestamp { + fun format(zoneId: ZoneId, formatter: DateTimeFormatter): String + + data class Parsed(val value: Instant) : Timestamp { + override fun format(zoneId: ZoneId, formatter: DateTimeFormatter): String { + return value.atZone(zoneId).format(formatter) + } + } + + data class Fallback(val value: String) : Timestamp { + override fun format(zoneId: ZoneId, formatter: DateTimeFormatter): String { + return value + } + } + + companion object { + fun fromEpochMilli(value: Long): Parsed { + return Parsed(Instant.ofEpochMilli(value)) + } + + fun fromString(value: String): Timestamp { + return try { + // Use OffsetDateTime.parse instead of Instant.parse because Instant.parse in JDK <= 11 does not support non-UTC offset like "-05:00". + // See: https://stackoverflow.com/questions/68217689/how-to-use-instant-java-class-to-parse-a-date-time-with-offset-from-utc/68221614#68221614 + Parsed(OffsetDateTime.parse(value).toInstant()) + } catch (e: DateTimeParseException) { + Fallback(value) + } + } + } +} + +private val timestampKeys = listOf("timestamp", "time", "@timestamp") + +fun extractTimestamp(node: JsonNode): Timestamp? { + + return timestampKeys.firstNotNullOfOrNull { node.get(it) }?.let { timestampNode -> + if (timestampNode.isNumber) { + // We assume that the number is a Unix timestamp in milliseconds. + Timestamp.fromEpochMilli(timestampNode.asLong()) + } else { + Timestamp.fromString(timestampNode.asText()) + } + } +} diff --git a/src/test/kotlin/io/github/orangain/prettyjsonlog/json/ParseTest.kt b/src/test/kotlin/io/github/orangain/prettyjsonlog/json/ParseTest.kt new file mode 100644 index 0000000..afab02c --- /dev/null +++ b/src/test/kotlin/io/github/orangain/prettyjsonlog/json/ParseTest.kt @@ -0,0 +1,31 @@ +package io.github.orangain.prettyjsonlog.json + +import junit.framework.TestCase + +class ParseTest : TestCase() { + fun testParseJsonLine() { + val result = parseJson("""{"key": "value"}""") + assertNotNull(result) + val (node, rest) = result!! + assertEquals("""{"key":"value"}""", node.toString()) + assertEquals("", rest) + } + + fun testParseJsonLineWithSpaces() { + val result = parseJson(""" {"key": "value"} """) + assertNotNull(result) + val (node, rest) = result!! + assertEquals("""{"key":"value"}""", node.toString()) + assertEquals(" ", rest) + } + + fun testParseBrokenJsonLine() { + val result = parseJson("""{"key": "value" """) + assertNull(result) + } + + fun testParseEmptyString() { + val result = parseJson("") + assertNull(result) + } +} diff --git a/src/test/kotlin/io/github/orangain/prettyjsonlog/ExtractTest.kt b/src/test/kotlin/io/github/orangain/prettyjsonlog/logentry/ExtractTest.kt similarity index 99% rename from src/test/kotlin/io/github/orangain/prettyjsonlog/ExtractTest.kt rename to src/test/kotlin/io/github/orangain/prettyjsonlog/logentry/ExtractTest.kt index 2f815e7..9b92127 100644 --- a/src/test/kotlin/io/github/orangain/prettyjsonlog/ExtractTest.kt +++ b/src/test/kotlin/io/github/orangain/prettyjsonlog/logentry/ExtractTest.kt @@ -1,4 +1,4 @@ -package io.github.orangain.prettyjsonlog +package io.github.orangain.prettyjsonlog.logentry import io.github.orangain.prettyjsonlog.json.parseJson import junit.framework.TestCase