Add expectations.

This commit is contained in:
2025-12-19 09:10:13 -06:00
parent 45db33bf9e
commit c269020227
2 changed files with 417 additions and 55 deletions

View File

@@ -1,6 +1,6 @@
# Package # Package
version = "0.2.4" version = "1.0.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Small utility to pretty-print strucutured logs." description = "Small utility to pretty-print strucutured logs."
license = "MIT" license = "MIT"
@@ -11,4 +11,4 @@ bin = @["slfmt"]
# Dependencies # Dependencies
requires @["nim >= 2.2.0", "docopt >= 0.7.1"] requires @["nim >= 2.2.0", "docopt >= 0.7.1"]
requires @["timeutils", "zero_functional"] requires @["cliutils >= 0.11.0", "timeutils", "zero_functional"]

View File

@@ -1,10 +1,11 @@
import std/[json, options, sequtils, streams, strutils, terminal, times] import std/[atomics, json, options, os, posix, sequtils, setutils, strutils,
import docopt, timeutils, zero_functional terminal, termios, times, unicode]
import cliutils, docopt, timeutils, zero_functional
from std/logging import Level from std/logging import Level
from std/sequtils import toSeq import std/nre except toSeq
const VERSION = "0.2.4" const VERSION = "1.0.0"
const USAGE = """Usage: const USAGE = """Usage:
slfmt [options] slfmt [options]
@@ -15,11 +16,182 @@ Options:
-c, --compact Compact output -c, --compact Compact output
-l, --log-level <lvl> Only show log events at or above this level -l, --log-level <lvl> Only show log events at or above this level
-n, --namespace <ns> Only show log events from this namespace -n, --namespace <ns> Only show log events from this namespace
-e, --expected <expected>
Add a filter for something expected to be present in the logs. It's
absence will be flagged. On the command-line, <expected> should be
formatted as:
field-name:regex-pattern:label
<label> is optional and when provided is used as the value displayed
when this expectation is flagged. If the log stream is not a structured
log, the <field-name> is ignored and the log text is matched directly
against the log line. Multiple expectations can be provided, separating
each with ';'.
-E, --unexpected <unexpected>
Add a filter for something that should not be present in the logs. It's
presence will be flagged. On the command-line, <unexpected> should be
formatted as:
field-name:regex-pattern:label
This option can be provided multiple times. <label> is optional and
when provided is used as the value displayed when this expectation is
flagged. If the log stream is not a structured log, the <field-name> is
ignored and the log text is matched directly against the log line.
Multiple expectations can be provided, separating each with ';'.
--config <cfgFile>
Read expectation data from a json config file formatted as follows:
[
{
"fieldName": "<field-name>",
"label": "<label value>", // optional
"pattern": "<regex pattern>",
"expected": false,
"style": { // optional
"fg": "brightwhite",
"bg": "red",
"style": ["bold"]
},
// ...
]
The file should be a valid JSON array of expectation objects. Each
object must include fieldName, label, pattern, and expected fields. The
expected field determines whether this field should or should not be
found in the output. If set to true, this expectation operates like
--expected. If set to false it operates like --unexpected.
If label is not present or is null, the fieldName:pattern combination
will be used, similar to the CLI arguments behavior.
If the style field is set, it must be an object with fg, bg, and style
keys. There are three types of values that can be passed to fg and bg:
1. A string value representing named terminal color. The list of
recognized colors is
black, red, green, yellow, blue, magenta, cyan, white, default
There are also bright versions of each (except default) for terminals
that support them. For example, brightred, brightblack, etc.
For example:
"fg": "red",
2. A number representing an 8-bit color value (1-255). For example:
"bg": 60,
3. An object with r, g, and b fields representing a 24-bit Truecolor
value. For example:
"fg": { "r": 22, "g": 57, "b": 105 }
If there is no style set, one of the default styles is used (like when
using the CLI args to define expectations).
""" """
type
CtrlCmd = enum ccQuit, ccReset
Expectation = ref object
fieldName: string
label: string
pattern: Regex
expected: bool
count: int
termStyleCode: string # ANSI escape code
const fieldDisplayOrder = @[ const fieldDisplayOrder = @[
"scope", "level", "ts", "code", "sid", "sub", "msg", "err", "stack", "method", "args"] "scope", "level", "ts", "code", "sid", "sub", "msg", "err", "stack", "method", "args"]
let FORMATTING_REGEX* = re("\x1b\\[([0-9;]*)([a-zA-Z])")
const EXPECTATION_STYLES = [
(ansiEscSeq(fg = cBrightWhite) & ansiEscSeq(bg = (r: 105, g: 22, b:33))), # red
(ansiEscSeq(fg = cBrightWhite) & ansiEscSeq(bg = (r: 62, g: 100, b:15))), # green
(ansiEscSeq(fg = cBrightWhite) & ansiEscSeq(bg = (r: 22, g: 57, b:105))), # blue
(ansiEscSeq(fg = cBrightWhite) & ansiEscSeq(bg = (r: 79, g: 79, b:32))), # yellow
(ansiEscSeq(fg = cBrightWhite) & ansiEscSeq(bg = (r: 73, g: 55, b:78))), # magenta
(ansiEscSeq(fg = cBrightWhite) & ansiEscSeq(bg = (r: 41, g: 84, b:98))) # cyan
]
var
controlChannel: Channel[CtrlCmd]
stopFlag: Atomic[bool]
proc handleSignal(sig: cint) {.noconv.} = stopFlag.store(true)
proc stripAnsi*(text: string): string =
text.replace(FORMATTING_REGEX, "")
proc initializeTTY(tty: File): Termios =
let ttyFd = tty.getFileHandle.cint
var mode: Termios = default(Termios)
if ttyFd.tcGetAttr(addr mode) != 0:
raise newException(Exception, "couldn't read TTY attributes")
result = mode
mode.c_iflag = mode.c_iflag and not Cflag(BRKINT or ICRNL or INPCK or
ISTRIP or IXON)
mode.c_cflag = (mode.c_cflag and not Cflag(CSIZE or PARENB)) or CS8
mode.c_lflag = (mode.c_lflag and not Cflag(ECHO or ICANON or IEXTEN)) or ISIG
mode.c_cc[VMIN] = 1.char
mode.c_cc[VTIME] = 0.char
if ttyFd.tcSetAttr(TCSANOW, addr mode) != 0:
raise newException(Exception, "couldn't set TTY attributes")
proc restoreTTY(tty: File, ttyMode: var Termios) =
discard tcSetAttr(tty.getFileHandle.cint, TCSADRAIN, addr ttyMode)
proc readTTYLoop() {.thread.} =
var tty: File
var ttyMode: Termios = default(Termios)
if not open(tty, "/dev/tty", fmRead) or not isatty(tty):
return # bail and don't worry about reading control characters
try:
ttyMode = initializeTTY(tty)
while not stopFlag.load():
let ch = tty.readChar()
case ch
of '\x12', 'r': controlChannel.send(ccReset) # Ctrl-R
of '\x04' : controlChannel.send(ccQuit) # Ctrl-D
else: discard
finally:
restoreTTY(tty, ttyMode)
close(tty)
func getOrFail(n: JsonNode, key: string): JsonNode =
if not n.contains(key):
raise newException(ValueError, "missing " & key &
" field in the following JSON node:\n" & n.pretty)
n[key]
func parseLogLevel(s: string): Level = func parseLogLevel(s: string): Level =
case s.toUpper case s.toUpper
of "DEBUG": result = Level.lvlDebug of "DEBUG": result = Level.lvlDebug
@@ -30,99 +202,253 @@ func parseLogLevel(s: string): Level =
of "FATAL": result = Level.lvlFatal of "FATAL": result = Level.lvlFatal
else: result = Level.lvlAll else: result = Level.lvlAll
func decorate(
s: string,
fg = fgDefault,
style: set[Style] = {}): string =
result = "" proc processFormattedFieldExpectations(
expectations: seq[Expectation] = @[],
value: string): string =
if style != {}: result = value
result &= toSeq(items(style)).mapIt(ansiStyleCode(it)).join("")
if fg != fgDefault: result &= ansiForegroundColorCode(fg) for exp in expectations:
let match = find(stripAnsi(value), exp.pattern)
if match.isSome:
inc exp.count
result &= s & ansiResetCode if not exp.expected:
let bounds = match.get.matchBounds
result =
ansiAwareSubstring(result, (0..<bounds.a)) &
exp.termStyleCode &
stripAnsi(ansiAwareSubstring(result, (bounds.a .. bounds.b))) &
RESET_FORMATTING &
ansiAwareSubstring(result, (bounds.b + 1 ..< ^1))
proc fullFormatField(name: string, value: JsonNode): string = proc processFormattedFieldExpectations(
result = decorate(name, fgCyan) & ":" & " ".repeat(max(1, 10 - name.len)) name: string,
expectations: seq[Expectation],
value: string): string =
processFormattedFieldExpectations(
expectations.filterIt(it.fieldName == name),
value)
proc fullFormatField(
name: string,
value: JsonNode,
expectations: seq[Expectation] = @[]): string =
result = color(name, cCyan) & ":" & " ".repeat(max(1, 10 - name.len))
var strVal: string = "" var strVal: string = ""
case name: case name:
of "ts": of "ts":
let dt = parseIso8601(value.getStr) let dt = parseIso8601(value.getStr)
strVal = decorate(dt.local.formatIso8601 & " (local) ", fgBlue, {styleBright}) & strVal =
color(dt.local.formatIso8601 & " (local) ", cBrightBlue) &
dt.utc.formatIso8601 & " (UTC)" dt.utc.formatIso8601 & " (UTC)"
of "sid", "sub": strVal = decorate(value.getStr, fgGreen) of "sid", "sub": strVal = color(value.getStr, cGreen)
of "err": strVal = decorate(value.getStr, fgRed) of "err": strVal = color(value.getStr, cRed)
of "msg": strVal = decorate(value.getStr, fgYellow) of "msg": strVal = color(value.getStr, cYellow)
of "stack": strVal = decorate(value.getStr, fgBlack, {styleBright}) of "stack": strVal = color(value.getStr, cBrightBlack)
else: else:
if value.kind == JString: strVal = decorate(value.getStr) if value.kind == JString: strVal = value.getStr
else: strVal = pretty(value) else: strVal = pretty(value)
strVal = processFormattedFieldExpectations(name, expectations, strVal)
let valLines = splitLines(strVal) let valLines = splitLines(strVal)
if name.len + strVal.len + 6 > terminalWidth() or valLines.len > 1: if name.len + strVal.len + 6 > terminalWidth() or valLines.len > 1:
result &= "\p" & valLines.mapIt(" " & it).join("\p") & "\p" result &= "\p" & valLines.mapIt(" " & it).join("\p") & "\p"
else: result &= strVal & "\p" else: result &= strVal & "\p"
proc fullFormat(logJson: JsonNode): string = proc fullFormat(logJson: JsonNode, expectations: seq[Expectation] = @[]): string =
result = '-'.repeat(terminalWidth()) & "\p" result = '-'.repeat(terminalWidth()) & "\p"
# Print the known fields in order first # Print the known fields in order first
for f in fieldDisplayOrder: for f in fieldDisplayOrder:
if logJson.hasKey(f): if logJson.hasKey(f):
result &= fullFormatField(f, logJson[f]) result &= fullFormatField(f, logJson[f], expectations)
logJson.delete(f) logJson.delete(f)
# Print the rest of the fields # Print the rest of the fields
for (key, val) in pairs(logJson): result &= fullFormatField(key, val) for (key, val) in pairs(logJson):
result &= fullFormatField(key, val, expectations)
result &= "\p" result &= "\p"
proc compactFormat(logJson: JsonNode): string = proc compactFormat(logJson: JsonNode, expectations: seq[Expectation] = @[]): string =
let ts = parseIso8601(logJson["ts"].getStr).local.formatIso8601 var ts = parseIso8601(logJson["ts"].getStr).local.formatIso8601
var level = logJson["level"].getStr ts = color(alignLeft(ts[0..21], 23), cBrightBlue)
if level == "ERROR": level = decorate(alignLeft(level, 7), fgRed) ts = processFormattedFieldExpectations("ts", expectations, ts)
elif level == "WARN": level = decorate(alignLeft(level, 7), fgYellow)
else: level = alignLeft(level, 7)
result = "$1 $2 $3: $4" % [ var level = logJson["level"].getStr
level, if level == "ERROR": level = color(alignLeft(level, 7), cRed)
decorate(alignLeft(ts[0..21], 23), fgBlue, {styleBright}), elif level == "WARN": level = color(alignLeft(level, 7), cYellow)
decorate(logJson["scope"].getStr, fgGreen), else: level = alignLeft(level, 7)
decorate(logJson["msg"].getStr, fgYellow)] level = processFormattedFieldExpectations("level", expectations, level)
let scope = processFormattedFieldExpectations("scope", expectations,
color(logJson["scope"].getStr, cGreen))
let msg = processFormattedFieldExpectations("msg", expectations,
color(logJson["msg"].getStr, cYellow))
result = "$1 $2 $3: $4" % [ level, ts, scope, msg]
let restNodes = (toSeq(pairs(logJson))) --> let restNodes = (toSeq(pairs(logJson))) -->
filter(not ["level", "scope", "ts", "msg"].contains(it[0])) filter(not ["level", "scope", "ts", "msg"].contains(it[0]))
let restMsg = join(restNodes --> let restMsg = join(restNodes -->
map("$1: $2" % [ map("$1: $2" % [
decorate(it[0], fgCyan), color(it[0], cCyan),
processFormattedFieldExpectations(it[0], expectations,
if it[1].kind == JString: it[1].getStr if it[1].kind == JString: it[1].getStr
else: pretty(it[1]), else: pretty(it[1])),
" "])) " "]))
if restMsg.len + result.len + 2 < terminalWidth(): if runeLen(stripAnsi(restMsg)) + runeLen(stripAnsi(result)) + 2 < terminalWidth():
result &= " " & restMsg result &= " " & restMsg
else: else:
var line = " " var line = " "
for (key, val) in restNodes: for (key, val) in restNodes:
let fieldVal = let fieldVal = processFormattedFieldExpectations(key, expectations,
if val.kind == JString: val.getStr if val.kind == JString: val.getStr
else: pretty(val) else: pretty(val))
let field = "$1: $2" % [decorate(key, fgCyan), fieldVal] let field = "$1: $2" % [color(key, cCyan), fieldVal]
if line.len + field.len + 2 > terminalWidth(): if runeLen(stripAnsi(line)) + runeLen(stripAnsi(field)) + 2 > terminalWidth():
result &= "\p " & line result &= "\p " & line
line = " " line = " "
line &= field & " " line &= field & " "
result &= "\p " & line result &= "\p " & line
func formatExpectationLabels(expectations: seq[Expectation]): string =
let maxLabelLen = expectations.mapIt(runeLen(it.label)).max(system.cmp)
let maxCountLen = runeLen($expectations.mapIt(it.count).max(system.cmp))
let expectationLabels = expectations.map(proc (exp: Expectation): string =
let label = alignLeft(exp.label, maxLabelLen)
let count = alignLeft($exp.count, maxCountLen)
result = exp.termStyleCode & label & RESET_FORMATTING & " - "
if (exp.expected and exp.count == 0) or
(not exp.expected and exp.count > 0):
result &= termFmt(count, fg = cBrightRed, bg = cDefault, style = {tsBold})
else:
result &= count
)
return "[ " & expectationLabels.join(" | ") & " ]"
proc parseLogLine(logLine: string): JsonNode = proc parseLogLine(logLine: string): JsonNode =
result = parseJson(logLine) result = parseJson(logLine)
proc parseExpectations(args: Table[string, docopt.Value]): seq[Expectation] =
result = @[]
for isExpected in [true, false]:
let argName = if isExpected: "--expected" else: "--unexpected"
if args[argName]:
let exptArgs = split($args[argName], ';')
for ea in exptArgs:
let parts = split(ea, ':', 2)
result.add(Expectation(
fieldName: parts[0],
pattern: re(parts[1]),
termStyleCode: EXPECTATION_STYLES[result.len mod EXPECTATION_STYLES.len],
label:
if parts.len > 2: parts[2]
else: ea,
expected: isExpected,
count: 0))
if args["--config"]:
let filename = $args["--config"]
if not fileExists(filename):
stderr.writeLine("slfmt - WARN: " &
filename & " does not exist, ignoring")
try:
let cfg: JsonNode = parseFile(filename)
for expJson in cfg.getElems:
var exp = Expectation(
fieldName: expJson.getOrFail("fieldName").getStr,
pattern: re(expJson.getOrFail("pattern").getStr),
expected: expJson.getOrFail("expected").getBool,
termStyleCode: EXPECTATION_STYLES[result.len mod EXPECTATION_STYLES.len],
label:
if expJson.contains("label") and expJson["label"].kind == JString:
expJson["label"].getStr
else:
expJson.getOrFail("fieldName").getStr & ":" &
expJson.getOrFail("pattern").getStr)
if expJson.contains("style"):
let styleJson = expJson["style"]
let fmtStyles: set[TerminalStyle] = styleJson
.getOrFail("style")
.getElems
.mapIt(parseEnum[TerminalStyle]("ts" & it.getStr))
.toSet
let fgJson = styleJson.getOrFail("fg")
let bgJson = styleJson.getOrFail("bg")
if fgJson.kind == JString and bgJson.kind == JString:
# simple case, both fg and bg are TerminalColors
exp.termStyleCode = ansiEscSeq(
fg = parseEnum[TerminalColors]("c" & fgJson.getStr),
bg = parseEnum[TerminalColors]("c" & bgJson.getStr),
fmtStyles)
else:
exp.termStyleCode = ansiEscSeq(fmtStyles)
case fgJson.kind
of JString:
exp.termStyleCode &= ansiEscSeq(
fg = parseEnum[TerminalColors]("c" & fgJson.getStr))
of JInt: exp.termStyleCode &= ansiEscSeq(fg = fgJson.getInt)
of JObject:
exp.termStyleCode &= ansiEscSeq(fg = (
r: fgJson.getOrFail("r").getInt,
g: fgJson.getOrFail("g").getInt,
b: fgJson.getOrFail("b").getInt))
else:
raise newException(ValueError,
"The 'fg' field must be a string, number, or object.")
case bgJson.kind
of JString:
exp.termStyleCode &= ansiEscSeq(
bg = parseEnum[TerminalColors]("c" & bgJson.getStr))
of JInt: exp.termStyleCode &= ansiEscSeq(bg = bgJson.getInt)
of JObject:
exp.termStyleCode &= ansiEscSeq(bg = (
r: bgJson.getOrFail("r").getInt,
g: bgJson.getOrFail("g").getInt,
b: bgJson.getOrFail("b").getInt))
else:
raise newException(ValueError,
"The 'bg' field must be a string, number, or object.")
result.add(exp)
except:
stderr.writeLine("slfmt - WARN: unable to parse config file, ignoring.")
stderr.writeLine("slfmt - DEBUG: " & getCurrentExceptionMsg())
proc eraseAndWriteLine(f: File, s: string) =
f.writeline(eraseLine(emAll) & s)
when isMainModule: when isMainModule:
var readerThread: Thread[void]
try: try:
let args = docopt(USAGE, version = VERSION) let args = docopt(USAGE, version = VERSION)
@@ -136,9 +462,26 @@ when isMainModule:
let compact = args["--compact"] let compact = args["--compact"]
let expectations = parseExpectations(args)
signal(SIGINT, handleSignal)
signal(SIGTERM, handleSignal)
#[
open(controlChannel)
createThread(readerThread, readTTYLoop)
]#
#stdout.hideCursor
#
stdout.writeLine("") # burn a line
var line: string = "" var line: string = ""
let sin = newFileStream(stdin) while stdin.readLine(line) and not stopFlag.load():
while(sin.readLine(line)): if expectations.len > 0:
stdout.eraseLine
stdout.cursorUp
stdout.eraseLine
try: try:
let logJson = parseLogLine(line) let logJson = parseLogLine(line)
if logJson.kind != JObject: if logJson.kind != JObject:
@@ -151,11 +494,30 @@ when isMainModule:
if namespace.isSome and logJson.hasKey("scope"): if namespace.isSome and logJson.hasKey("scope"):
if not logJson["scope"].getStr.startsWith(namespace.get): continue if not logJson["scope"].getStr.startsWith(namespace.get): continue
if compact: stdout.writeLine(compactFormat(logJson)) if compact: stdout.writeLine(compactFormat(logJson, expectations))
else: stdout.writeLine(fullFormat(logJson)) else: stdout.writeLine(fullFormat(logJson, expectations))
except ValueError, JsonParsingError: except ValueError, JsonParsingError:
stdout.writeLine(line) stdout.writeLine(processFormattedFieldExpectations(expectations, line))
finally:
if expectations.len > 0:
let labelsMsg = formatExpectationLabels(expectations)
stdout.writeLine(termFmt(repeat("", terminalWidth()),
fg = cDefault, bg = cDefault, style = {tsDim}))
stdout.write(labelsMsg)
stdout.cursorBackward(len(stripAnsi(labelsMsg)))
stdout.flushFile
except: except:
stderr.writeLine("slfmt - FATAL: " & getCurrentExceptionMsg()) stderr.writeLine("slfmt - FATAL: " & getCurrentExceptionMsg())
stderr.writeLine(getCurrentException().getStackTrace()) stderr.writeLine(getCurrentException().getStackTrace())
quit(QuitFailure) quit(QuitFailure)
finally:
stopFlag.store(true)
#close(controlChannel)
#joinThreads(readerThread)
stdout.writeLine(RESET_FORMATTING)
stdout.write(eraseLine(emAll))
stdout.showCursor