Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
8b0a684dab | |||
4efc72e8ae | |||
2291b75c0a | |||
11cc9c9a39 | |||
6c978f32cc | |||
34add1a729 | |||
982680d972 |
87
README.md
Normal file
87
README.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Structured Log Formatter
|
||||
**Terminal utility to pretty-print JSON-strucutred log lines.**
|
||||
|
||||
## Usage
|
||||
|
||||
`slfmt` is intended to be used to filter logging output from a process. It
|
||||
reads from `stdin` and writes to `stdout`. It expects to see a JSON object on
|
||||
each line of input, and will pretty-print the object to `stdout`. Escape codes
|
||||
in the log lines (`\n` for newlines, etc.) are supported and will be expanded
|
||||
in the output.
|
||||
|
||||
Any lines that are not valid JSON objects will be printed as-is to `stdout`.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
./run-server | slfmt
|
||||
```
|
||||
|
||||
Sample Output:
|
||||
|
||||
```
|
||||
---------------------------------------------------------------------------------------
|
||||
scope: "server/rest_api"
|
||||
level: "ERROR"
|
||||
ts:
|
||||
2025-01-03T08:46:35-06:00 (local) 2025-01-03T14:46:35Z (UTC)
|
||||
msg: unhandled exception (DbError)
|
||||
err:
|
||||
ERROR: relation "users" does not exist
|
||||
LINE 1: SELECT id FROM users WHERE id = '43422d65-6874-44ce-b9b3-f8c...
|
||||
^
|
||||
|
||||
stack:
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy.nim(519) workerProc
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy.nim(450) runTask
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy/routers.nim(271) :anonymous
|
||||
/home/username/projects/server/api/src/main/nim/server_api/api.nim(65) :anonymous
|
||||
/home/username/projects/server/api/src/main/nim/server_api/auth.nim(74) extractSession
|
||||
/home/username/projects/fiber-orm-nim/src/fiber_orm.nim(599) createOrUpdateUser
|
||||
/home/username/projects/fiber-orm-nim/src/fiber_orm.nim(384) createOrUpdateRecord
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/db_connector-0.1.0-d68319e3785fa937f0465ea915e942b61b6b5442/db_connector/db_postgres.nim(497) getAllRows
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/db_connector-0.1.0-d68319e3785fa937f0465ea915e942b61b6b5442/db_connector/db_postgres.nim(174) setupQuery
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/db_connector-0.1.0-d68319e3785fa937f0465ea915e942b61b6b5442/db_connector/db_postgres.nim(116) dbError
|
||||
[[reraised from:
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy.nim(519) workerProc
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy.nim(450) runTask
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy/routers.nim(271) :anonymous
|
||||
/home/username/projects/server/api/src/main/nim/server_api/api.nim(65) :anonymous
|
||||
/home/username/projects/server/api/src/main/nim/server_api/auth.nim(74) extractSession
|
||||
/home/username/projects/fiber-orm-nim/src/fiber_orm.nim(727) createOrUpdateUser
|
||||
]]
|
||||
|
||||
|
||||
|
||||
---------------------------------------------------------------------------------------
|
||||
scope: "server/rest_api"
|
||||
level: "ERROR"
|
||||
ts:
|
||||
2025-01-03T08:46:35-06:00 (local) 2025-01-03T14:46:35Z (UTC)
|
||||
msg: unhandled exception (DbError)
|
||||
err:
|
||||
ERROR: relation "users" does not exist
|
||||
LINE 1: SELECT id FROM users WHERE id = '43422d65-6874-44ce-b9b3-f8c...
|
||||
^
|
||||
|
||||
stack:
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy.nim(519) workerProc
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy.nim(450) runTask
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy/routers.nim(271) :anonymous
|
||||
/home/username/projects/server/api/src/main/nim/server_api/api.nim(65) :anonymous
|
||||
/home/username/projects/server/api/src/main/nim/server_api/auth.nim(74) extractSession
|
||||
/home/username/projects/fiber-orm-nim/src/fiber_orm.nim(599) createOrUpdateUser
|
||||
/home/username/projects/fiber-orm-nim/src/fiber_orm.nim(384) createOrUpdateRecord
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/db_connector-0.1.0-d68319e3785fa937f0465ea915e942b61b6b5442/db_connector/db_postgres.nim(497) getAllRows
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/db_connector-0.1.0-d68319e3785fa937f0465ea915e942b61b6b5442/db_connector/db_postgres.nim(174) setupQuery
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/db_connector-0.1.0-d68319e3785fa937f0465ea915e942b61b6b5442/db_connector/db_postgres.nim(116) dbError
|
||||
[[reraised from:
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy.nim(519) workerProc
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy.nim(450) runTask
|
||||
/home/username/.local/share/mise/installs/nim/2.2.0/nimble/pkgs2/mummy-0.4.5-cb7f70cd4d6fd3a563e00e664cfbd8cbd3c39b79/mummy/routers.nim(271) :anonymous
|
||||
/home/username/projects/server/api/src/main/nim/server_api/api.nim(65) :anonymous
|
||||
/home/username/projects/server/api/src/main/nim/server_api/auth.nim(74) extractSession
|
||||
/home/username/projects/fiber-orm-nim/src/fiber_orm.nim(727) createOrUpdateUser
|
||||
]]
|
||||
|
||||
```
|
@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "0.1.0"
|
||||
version = "0.2.3"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Small utility to pretty-print strucutured logs."
|
||||
license = "MIT"
|
||||
@ -10,5 +10,5 @@ bin = @["slfmt"]
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 2.2.0"
|
||||
requires "timeutils"
|
||||
requires @["nim >= 2.2.0", "docopt >= 0.7.1"]
|
||||
requires @["timeutils", "zero_functional"]
|
||||
|
125
src/slfmt.nim
125
src/slfmt.nim
@ -1,19 +1,35 @@
|
||||
import std/[json, sequtils, streams, strutils, terminal, times]
|
||||
import timeutils
|
||||
#import docopt
|
||||
import std/[json, options, sequtils, streams, strutils, terminal, times]
|
||||
import docopt, timeutils, zero_functional
|
||||
|
||||
const VERSION = "0.1.0"
|
||||
from std/logging import Level
|
||||
from std/sequtils import toSeq
|
||||
|
||||
# const USAGE = """Usage:
|
||||
# slfmt
|
||||
#
|
||||
# Options:
|
||||
#
|
||||
# -h, --help Print this usage and help information
|
||||
const VERSION = "0.2.3"
|
||||
|
||||
const USAGE = """Usage:
|
||||
slfmt [options]
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help Print this usage and help information
|
||||
-c, --compact Compact output
|
||||
-l, --log-level <lvl> Only show log events at or above this level
|
||||
-n, --namespace <ns> Only show log events from this namespace
|
||||
"""
|
||||
|
||||
const fieldDisplayOrder = @[
|
||||
"scope", "level", "ts", "code", "sid", "sub", "msg", "err", "stack", "method", "args"]
|
||||
|
||||
func parseLogLevel(s: string): Level =
|
||||
case s.toUpper
|
||||
of "DEBUG": result = Level.lvlDebug
|
||||
of "INFO": result = Level.lvlInfo
|
||||
of "NOTICE": result = Level.lvlNotice
|
||||
of "WARN": result = Level.lvlWarn
|
||||
of "ERROR": result = Level.lvlError
|
||||
of "FATAL": result = Level.lvlFatal
|
||||
else: result = Level.lvlAll
|
||||
|
||||
func decorate(
|
||||
s: string,
|
||||
fg = fgDefault,
|
||||
@ -29,7 +45,7 @@ func decorate(
|
||||
result &= s & ansiResetCode
|
||||
|
||||
|
||||
proc formatField(name: string, value: JsonNode): string =
|
||||
proc fullFormatField(name: string, value: JsonNode): string =
|
||||
result = decorate(name, fgCyan) & ":" & " ".repeat(max(1, 10 - name.len))
|
||||
|
||||
var strVal: string = ""
|
||||
@ -42,38 +58,103 @@ proc formatField(name: string, value: JsonNode): string =
|
||||
of "err": strVal = decorate(value.getStr, fgRed)
|
||||
of "msg": strVal = decorate(value.getStr, fgYellow)
|
||||
of "stack": strVal = decorate(value.getStr, fgBlack, {styleBright})
|
||||
else:
|
||||
if value.kind == JString: strVal = decorate(value.getStr)
|
||||
else: strVal = pretty(value)
|
||||
|
||||
let valLines = splitLines(strVal)
|
||||
if name.len > 10 or strVal.len + 16 > terminalWidth() or valLines.len > 1:
|
||||
if name.len + strVal.len + 6 > terminalWidth() or valLines.len > 1:
|
||||
result &= "\n" & valLines.mapIt(" " & it).join("\n") & "\n"
|
||||
else: result &= strVal & "\n"
|
||||
|
||||
proc prettyPrintFormat(logLine: string): string =
|
||||
try:
|
||||
var logJson = parseJson(logLine)
|
||||
|
||||
result = '-'.repeat(terminalWidth())
|
||||
proc fullFormat(logJson: JsonNode): string =
|
||||
result = '-'.repeat(terminalWidth()) & "\n"
|
||||
|
||||
# Print the known fields in order first
|
||||
for f in fieldDisplayOrder:
|
||||
if logJson.hasKey(f):
|
||||
result &= formatField(f, logJson[f])
|
||||
result &= fullFormatField(f, logJson[f])
|
||||
logJson.delete(f)
|
||||
|
||||
# Print the rest of the fields
|
||||
for (key, val) in pairs(logJson): result &= formatField(key, val)
|
||||
for (key, val) in pairs(logJson): result &= fullFormatField(key, val)
|
||||
|
||||
result &= "\n"
|
||||
|
||||
except ValueError, JsonParsingError:
|
||||
result = logLine
|
||||
proc compactFormat(logJson: JsonNode): string =
|
||||
let ts = parseIso8601(logJson["ts"].getStr).local.formatIso8601
|
||||
var level = logJson["level"].getStr
|
||||
if level == "ERROR": level = decorate(alignLeft(level, 7), fgRed)
|
||||
elif level == "WARN": level = decorate(alignLeft(level, 7), fgYellow)
|
||||
else: level = alignLeft(level, 7)
|
||||
|
||||
result = "$1 $2 $3: $4" % [
|
||||
level,
|
||||
decorate(alignLeft(ts[0..21], 23), fgBlue, {styleBright}),
|
||||
decorate(logJson["scope"].getStr, fgGreen),
|
||||
decorate(logJson["msg"].getStr, fgYellow)]
|
||||
|
||||
let restNodes = (toSeq(pairs(logJson))) -->
|
||||
filter(not ["level", "scope", "ts", "msg"].contains(it[0]))
|
||||
|
||||
let restMsg = join(restNodes -->
|
||||
map("$1: $2" % [
|
||||
decorate(it[0], fgCyan),
|
||||
if it[1].kind == JString: it[1].getStr
|
||||
else: pretty(it[1]),
|
||||
" "]))
|
||||
|
||||
if restMsg.len + result.len + 2 < terminalWidth():
|
||||
result &= " " & restMsg
|
||||
else:
|
||||
var line = " "
|
||||
for (key, val) in restNodes:
|
||||
let fieldVal =
|
||||
if val.kind == JString: val.getStr
|
||||
else: pretty(val)
|
||||
let field = "$1: $2" % [decorate(key, fgCyan), fieldVal]
|
||||
if line.len + field.len + 2 > terminalWidth():
|
||||
result &= "\n " & line
|
||||
line = " "
|
||||
line &= field & " "
|
||||
result &= "\n " & line
|
||||
|
||||
proc parseLogLine(logLine: string): JsonNode =
|
||||
result = parseJson(logLine)
|
||||
|
||||
when isMainModule:
|
||||
try:
|
||||
let args = docopt(USAGE, version = VERSION)
|
||||
|
||||
let logLevel =
|
||||
if args["--log-level"]: some(parseLogLevel($args["--log-level"]))
|
||||
else: none[Level]()
|
||||
|
||||
let namespace =
|
||||
if args["--namespace"]: some($args["--namespace"])
|
||||
else: none[string]()
|
||||
|
||||
let compact = args["--compact"]
|
||||
|
||||
var line: string = ""
|
||||
let sin = newFileStream(stdin)
|
||||
while(sin.readLine(line)): stdout.writeLine(prettyPrintFormat(line))
|
||||
while(sin.readLine(line)):
|
||||
try:
|
||||
let logJson = parseLogLine(line)
|
||||
if logJson.kind != JObject:
|
||||
raise newException(JsonParsingError, "Expected a JSON object")
|
||||
|
||||
if logLevel.isSome and logJson.hasKey("level"):
|
||||
let lvl = parseLogLevel(logJson["level"].getStr)
|
||||
if lvl < logLevel.get: continue
|
||||
|
||||
if namespace.isSome and logJson.hasKey("scope"):
|
||||
if not logJson["scope"].getStr.startsWith(namespace.get): continue
|
||||
|
||||
if compact: stdout.writeLine(compactFormat(logJson))
|
||||
else: stdout.writeLine(fullFormat(logJson))
|
||||
except ValueError, JsonParsingError:
|
||||
stdout.writeLine(line)
|
||||
except:
|
||||
stderr.writeLine("slfmt - FATAL: " & getCurrentExceptionMsg())
|
||||
stderr.writeLine(getCurrentException().getStackTrace())
|
||||
|
Loading…
x
Reference in New Issue
Block a user