338 lines
11 KiB
Nim

import
macros, strutils, strformat, sequtils, os
# The default behavior of Chronicles can be configured through a wide-range
# of compile-time -d: switches (for more information, see the README).
# This module implements the validation of all specified options and reduces
# them to a `Configuration` constant that can be accessed from the rest of
# the modules.
const
chronicles_enabled {.strdefine.} = "on"
chronicles_default_output_device* {.strdefine.} = "stdout"
chronicles_sinks* {.strdefine.} = ""
chronicles_streams* {.strdefine.} = ""
chronicles_enabled_topics {.strdefine.} = ""
chronicles_required_topics {.strdefine.} = ""
chronicles_disabled_topics {.strdefine.} = ""
chronicles_runtime_filtering {.strdefine.} = "off"
chronicles_log_level {.strdefine.} = when defined(release): "INFO"
else: "DEBUG"
chronicles_timestamps {.strdefine.} = "RfcTime"
chronicles_colors* {.strdefine.} = "NativeColors"
chronicles_indent {.intdefine.} = 2
chronicles_line_numbers {.strdefine.} = "off"
truthySwitches = ["yes", "1", "on", "true"]
falsySwitches = ["no", "0", "off", "false", "none"]
when chronicles_streams.len > 0 and chronicles_sinks.len > 0:
{.error: "Please specify only one of the options 'chronicles_streams' and 'chronicles_sinks'." }
when chronicles_enabled_topics.len > 0 and chronicles_required_topics.len > 0:
{.error: "Please specify only one of the options 'chronicles_enabled_topics' and 'chronicles_required_topics'." }
type
LogLevel* = enum
NONE,
TRACE,
DEBUG,
INFO,
NOTICE,
WARN,
ERROR,
FATAL
LogFormat* = enum
json,
textLines,
textBlocks
OutputDeviceKind* = enum
oStdOut,
oStdErr,
oFile,
oSysLog
oDynamic
LogFileMode = enum
Append,
Truncate
LogDestination* = object
case kind*: OutputDeviceKind
of oFile:
filename*: string
truncate*: bool
else:
discard
TimestampsScheme* = enum
NoTimestamps,
UnixTime,
RfcTime
ColorScheme* = enum
NoColors,
AnsiColors,
NativeColors
SinkSpec* = object
format*: LogFormat
colorScheme*: ColorScheme
timestamps*: TimestampsScheme
destinations*: seq[LogDestination]
StreamSpec* = object
name*: string
sinks*: seq[SinkSpec]
Configuration* = object
streams*: seq[StreamSpec]
EnabledTopic* = object
name*: string
logLevel*: LogLevel
const defaultChroniclesStreamName* = "defaultChroniclesStream"
proc handleYesNoOption(optName: string,
optValue: string): bool {.compileTime.} =
let canonicalValue = optValue.toLowerAscii
if canonicalValue in truthySwitches:
return true
elif canonicalValue in falsySwitches:
return false
else:
error &"A non-recognized value '{optValue}' for option '{optName}'. " &
"Please specify either 'on' or 'off'."
template handleYesNoOption(opt: untyped): bool =
handleYesNoOption(astToStr(opt), opt)
proc enumValues(E: typedesc[enum]): string =
result = mapIt(E, $it).join(", ")
proc handleEnumOption(E: typedesc[enum],
optName: string,
optValue: string): E {.compileTime.} =
try:
if optValue.toLowerAscii in falsySwitches:
type R = type(result)
return R(0)
else:
return parseEnum[E](optValue)
except: error &"'{optValue}' is not a recognized value for '{optName}'. " &
&"Allowed values are {enumValues E}"
template handleEnumOption(E, varName: untyped): auto =
handleEnumOption(E, astToStr(varName), varName)
template topicsAsSeq*(topics: string): untyped =
when topics.len > 0:
topics.split({','} + Whitespace)
else:
newSeq[string](0)
proc topicsWithLogLevelAsSeq(topics: string): seq[EnabledTopic] =
var sequence = newSeq[EnabledTopic](0)
if topics.len > 0:
for topic in split(topics, {','} + Whitespace):
var values = topic.split(':')
if values.len > 1:
if values[1].all(isDigit):
sequence.add(EnabledTopic(name: values[0],
logLevel: LogLevel(parseInt(values[1]))))
else:
sequence.add(EnabledTopic(name: values[0],
logLevel: handleEnumOption(LogLevel,
values[1])))
else:
sequence.add(EnabledTopic(name: values[0], logLevel: NONE))
return sequence
proc logFormatFromIdent(n: NimNode): LogFormat =
let format = $n
case format.toLowerAscii
of "json":
return json
of "textlines":
return textLines
of "textblocks":
return textBlocks
else:
error &"'{format}' is not a recognized output format. " &
&"Allowed values are {enumValues LogFormat}."
proc makeSinkSpec(fmt: LogFormat,
colors: ColorScheme,
timestamps: TimestampsScheme,
destinations: varargs[LogDestination]): SinkSpec =
result.format = fmt
result.colorScheme = colors
result.timestamps = timestamps
result.destinations = @destinations
func logDestinationFromStr(s: string): LogDestination {.compileTime.} =
case s.toLowerAscii
of "stdout": result.kind = oStdOut
of "stderr": result.kind = oStdErr
of "syslog": result.kind = oSysLog
of "dynamic": result.kind = oDynamic
of "file":
result.kind = oFile
result.filename = ""
result.truncate = false
else:
error &"'{s}' is not a recognized output device type. " &
"Allowed values are StdOut, StdErr, SysLog, File and Dynamic."
proc logDestinationFromNode(n: NimNode): LogDestination =
case n.kind
of nnkIdent:
result = logDestinationFromStr($n)
of nnkCall:
if n[0].kind != nnkIdent and ($n[0]).toLowerAscii != "file":
error &"Invalid log destination expression '{n.repr}'. " &
"Only 'file' destinations accept parameters."
result.kind = oFile
result.filename = n[1].repr.replace(" ", "")
if DirSep != '/': result.filename = result.filename.replace("/", $DirSep)
if n.len > 2:
result.truncate = handleEnumOption(LogFileMode, "file mode", $n[2]) == Truncate
else:
error &"Invalid log destination expression '{n.repr}'. " &
"Please refer to the documentation for the supported options."
const
defaultColorScheme = handleEnumOption(ColorScheme, chronicles_colors)
defaultTimestamsScheme = handleEnumOption(TimestampsScheme, chronicles_timestamps)
proc syntaxCheckStreamExpr*(n: NimNode) =
if n.kind != nnkBracketExpr or n[0].kind != nnkIdent:
error &"Invalid stream definition. " &
"Please use a bracket expressions such as 'stream_name[sinks_list]'."
proc sinkSpecsFromNode*(streamNode: NimNode): seq[SinkSpec] =
newSeq(result, 0)
for i in 1 ..< streamNode.len:
let n = streamNode[i]
case n.kind
of nnkIdent:
result.add makeSinkSpec(logFormatFromIdent(n),
defaultColorScheme,
defaultTimestamsScheme,
logDestinationFromStr(chronicles_default_output_device))
of nnkBracketExpr:
var spec = makeSinkSpec(logFormatFromIdent(n[0]),
defaultColorScheme,
defaultTimestamsScheme)
for i in 1 ..< n.len:
var hasExplicitColors = false
template setColors(c) =
spec.colorScheme = c
hasExplicitColors = true
continue
template setTimestamps(t) =
spec.timestamps = t
continue
let dstSpec = n[i]
if dstSpec.kind == nnkIdent:
case ($dstSpec).toLowerAscii:
of "nocolors": setColors(NoColors)
of "ansicolors": setColors(AnsiColors)
of "nativecolors": setColors(NativeColors)
of "notimestamps": setTimestamps(NoTimestamps)
of "unixtime": setTimestamps(UnixTime)
of "rfctime": setTimestamps(RfcTime)
else: discard
let dst = logDestinationFromNode(dstSpec)
if dst.kind == oSysLog and not hasExplicitColors:
spec.colorScheme = NoColors
spec.destinations.add dst
result.add spec
else:
error &"Invalid log sink expression '{n.repr}'. " &
"Please refer to the documentation for the supported options."
proc parseStreamsSpec(spec: string): Configuration {.compileTime.} =
newSeq(result.streams, 0)
var specNodes = parseExpr "(" & spec.replace("\\", "/") & ")"
for n in specNodes:
syntaxCheckStreamExpr(n)
let streamName = $n[0]
for prev in result.streams:
if prev.name == streamName:
error &"The stream name '{streamName}' appears twice in the 'chronicles_streams' definition."
result.streams.add StreamSpec(name: streamName,
sinks: sinkSpecsFromNode(n))
proc overlappingOutputsError(stream: StreamSpec, outputName: string) =
# XXX: This must be a proc until https://github.com/nim-lang/Nim/issues/7632 is fixed
error &"In the {stream.name} stream, there are multiple output formats pointed " &
&"to {outputName}. This is not a supported configuration."
for stream in mitems(result.streams):
var stdoutSinks = 0
var stderrSinks = 0
var syslogSinks = 0
for sink in mitems(stream.sinks):
for dst in mitems(sink.destinations):
case dst.kind
of oStdOut:
inc stdoutSinks
if stdoutSinks > 1: overlappingOutputsError(stream, "stdout")
of oStdErr:
inc stderrSinks
if stderrSinks > 1: overlappingOutputsError(stream, "stderr")
of oSysLog:
inc syslogSinks
if stderrSinks > 1: overlappingOutputsError(stream, "syslog")
if sink.colorScheme != NoColors:
error "Using a color scheme is not supported when logging to syslog."
when not defined(posix):
warning "Logging to syslog is available only on POSIX systems."
else: discard
proc parseSinksSpec(spec: string): Configuration {.compileTime.} =
return parseStreamsSpec(&"{defaultChroniclesStreamName}[{spec}]")
const
loggingEnabled* = handleYesNoOption chronicles_enabled
runtimeFilteringEnabled* = handleYesNoOption chronicles_runtime_filtering
enabledLogLevel* = handleEnumOption(LogLevel, chronicles_log_level)
textBlockIndent* = repeat(' ', chronicles_indent)
enabledTopics* = topicsWithLogLevelAsSeq chronicles_enabled_topics
disabledTopics* = topicsAsSeq chronicles_disabled_topics
requiredTopics* = topicsAsSeq chronicles_required_topics
lineNumbersEnabled* = handleYesNoOption chronicles_line_numbers
config* = when chronicles_streams.len > 0: parseStreamsSpec(chronicles_streams)
elif chronicles_sinks.len > 0: parseSinksSpec(chronicles_sinks)
# default is textlines because:
# * better compatibility with typical log processing tools
# like grep, logstash etc where newline delieates events or units
# * easier to match with a regex
# * good use of screen real estate
# * wins nimbus developer straw poll
# alternatively, one could prefer to use "textblocks" - it can be
# enabled by passing -d:chronicles_sinks=textblocks
# * some tools understand that indented lines following newline
# "belong" to the same logging eevent
# * wrapping more likely to happen making line hard to read on
# narrow terminals
# * properies may be easier to find
else: parseSinksSpec "textlines"