Compare commits
15 Commits
05f5c2548c
...
2.1.2
Author | SHA1 | Date | |
---|---|---|---|
15af03e5c5 | |||
7e1671b26e | |||
f0f0084cfd | |||
bff544ab89 | |||
3178c50936 | |||
1b598fb445 | |||
101ac8d869 | |||
a4464c7275 | |||
49755fa2af | |||
b2d43d1c6d | |||
c22e7edd5d | |||
269cc81c82 | |||
1884e07378 | |||
2f761833bd | |||
c4074007b5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ nimcache/
|
|||||||
tests/tests
|
tests/tests
|
||||||
src/namespaced_logging.out
|
src/namespaced_logging.out
|
||||||
src/namespaced_logging/autoconfigured
|
src/namespaced_logging/autoconfigured
|
||||||
|
.worktrees
|
||||||
|
212
README.md
212
README.md
@@ -1,63 +1,32 @@
|
|||||||
# Namespaced Logging for Nim
|
# Namespaced Logging for Nim
|
||||||
|
|
||||||
`namespaced_logging` provides a high-performance, thread-safe logging framework
|
`namespaced_logging` is intended to be a high-performance, thread-safe logging
|
||||||
similar to [std/logging][std-logging] with support for namespace-scoped logging
|
framework similar to [std/logging][std-logging] with support for
|
||||||
similar to [log4j][] or [logback][] for Nim. It has four main motivating
|
namespace-scoped logging similar to [log4j][] or [logback][] for Nim. It has
|
||||||
features:
|
four main motivating features:
|
||||||
- Hierarchical, namespaced logging
|
- Hierarchical, namespaced logging
|
||||||
- Safe and straightforward to use in multi-threaded applications.
|
- Safe and straightforward to use in multi-threaded applications.
|
||||||
- Native support for structured logging.
|
- Native support for structured logging.
|
||||||
- Simple, autoconfigured usage pattern mirroring the [std/logging][std-logging]
|
- Simple, autoconfigured usage pattern reminiscent of the
|
||||||
interface.
|
[std/logging][std-logging] interface (*not yet implemented*)
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Install the package from nimble:
|
Install the package via nimble:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nimble install namespaced_logging
|
# Not yet in official Nim packages. TODO once we've battle-tested it a little
|
||||||
|
nimble install https://github.com/jdbernard/nim-namespaced-logging
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage Patterns
|
## Usage Patterns
|
||||||
|
|
||||||
### Simple, Autoconfigured Setup
|
|
||||||
```nim
|
|
||||||
import namespaced_logging/autoconfigured
|
|
||||||
|
|
||||||
# Zero configuration of the LogService required, appender/logger configuration
|
|
||||||
# is immediately available
|
|
||||||
addLogAppender(initConsoleLogAppender())
|
|
||||||
info("Application started")
|
|
||||||
|
|
||||||
# Set global threshold
|
|
||||||
setRootLoggingThreshold(lvlWarn)
|
|
||||||
|
|
||||||
# Namespaced loggers, thresholds, and appenders supported
|
|
||||||
addLogAppender(initFileLogAppender(
|
|
||||||
filePath = "/var/log/app_db.log",
|
|
||||||
formatter = formatJsonStructuredLog, # provided in namespaced_logging
|
|
||||||
namespace = "app/db",
|
|
||||||
threshold = lvlInfo))
|
|
||||||
|
|
||||||
# in DB code
|
|
||||||
let dbLogger = getLogger("app/db/queryplanner")
|
|
||||||
dbLogger.debug("Beginning query plan...")
|
|
||||||
|
|
||||||
# native support for structured logs (import std/json)
|
|
||||||
dbLogger.debug(%*{
|
|
||||||
"method": "parseParams",
|
|
||||||
"message": "unrecognized param type",
|
|
||||||
"invalidType": $params[idx].type,
|
|
||||||
"metadata": %(params.meta)
|
|
||||||
} )
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Configuration
|
### Manual Configuration
|
||||||
```nim
|
```nim
|
||||||
import namespaced_logging
|
import namespaced_logging
|
||||||
|
|
||||||
# Manually creating a LogService. This is an independent logging root fully
|
# Manually creating a LogService. This is an independent logging root fully
|
||||||
# isolated from subsequent LogServices initialized
|
# isolated from subsequent LogServices initialized with initLogService
|
||||||
var ls = initLogService()
|
var ls = initLogService()
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -71,33 +40,6 @@ let apiLogger = localLogSvc.getLogger("api")
|
|||||||
let dbLogger = localLogSvc.getLogger("db")
|
let dbLogger = localLogSvc.getLogger("db")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Autoconfigured Multithreaded Application
|
|
||||||
```nim
|
|
||||||
import namespaced_logging/autoconfigured
|
|
||||||
import mummy, mummy/routers
|
|
||||||
|
|
||||||
# Main thread setup
|
|
||||||
addLogAppender(initConsoleLogAppender())
|
|
||||||
|
|
||||||
proc createApiRouter*(apiCtx: ProbatemApiContext): Router =
|
|
||||||
# This will run on a separate thread, but the thread creation is managed by
|
|
||||||
# mummy, not us. Log functions still operate correctly and respect the
|
|
||||||
# configuration setup on the main thread
|
|
||||||
let logger = getLogger("api")
|
|
||||||
logger.trace(%*{ "method_entered": "createApiRouter" })
|
|
||||||
|
|
||||||
# API route setup...
|
|
||||||
|
|
||||||
logger.debug(%*{ "method": "createApiRouter", "routes": numRoutes })
|
|
||||||
|
|
||||||
|
|
||||||
let server = newServer(createApiRouter(), workerThreads = 4)
|
|
||||||
info("Serving MyApp v1.0.0 on port 8080")
|
|
||||||
|
|
||||||
setThreshold("api", lvlTrace) # will be picked up by loggers on worker threads
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Manual Multithreaded Application
|
### Manual Multithreaded Application
|
||||||
```nim
|
```nim
|
||||||
import namespaced_logging
|
import namespaced_logging
|
||||||
@@ -219,9 +161,29 @@ to files associated with the *FileLogAppender* configured for the current
|
|||||||
`namespaced_logging` does not currently have built-in logic for file
|
`namespaced_logging` does not currently have built-in logic for file
|
||||||
rotation, but it does play nice with external file rotation strategies. We do
|
rotation, but it does play nice with external file rotation strategies. We do
|
||||||
not hold open file handles. The *FileLogAppender* attempts to batch messages
|
not hold open file handles. The *FileLogAppender* attempts to batch messages
|
||||||
by destination file, opens the file with fmAppend, writes the current batch of
|
by destination file, opens the file with mode `fmAppend`, writes the current
|
||||||
log messages, and then closes the file handle. Because of this, it has no
|
batch of log messages, and then closes the file handle. Because of this, it has
|
||||||
problem if another process moves or truncates any of the target log files.
|
no problem if another process moves or truncates any of the target log files.
|
||||||
|
|
||||||
|
### StdLoggingAppender
|
||||||
|
|
||||||
|
Provides a fallback to [std/logging][std-logging]-based logging. This is
|
||||||
|
primarily intended for use in libraries or other contexts where you want to
|
||||||
|
fall back to std/logging if the application is not using or hasn't configured
|
||||||
|
namespaced\_logging.
|
||||||
|
|
||||||
|
By default the *StdLoggingAppender* only logs when no namespaced\_logging
|
||||||
|
appenders are configured but it can also be configured to always forward log
|
||||||
|
messages regardless of whether namespaced\_logging has other appenders by
|
||||||
|
setting `fallbackOnly = false`.
|
||||||
|
|
||||||
|
```nim
|
||||||
|
func initStdLoggingAppender*(
|
||||||
|
fallbackOnly = true,
|
||||||
|
formatter = formatForwardedLog,
|
||||||
|
namespace = "",
|
||||||
|
threshold = lvlAll): StdLoggingAppender {.gcsafe.}
|
||||||
|
```
|
||||||
|
|
||||||
### CustomLogAppender
|
### CustomLogAppender
|
||||||
|
|
||||||
@@ -242,7 +204,7 @@ for the custom functionality.
|
|||||||
*TODO: rethink this. I chose this to avoid GC-safety issues copying closures
|
*TODO: rethink this. I chose this to avoid GC-safety issues copying closures
|
||||||
across threads, but maybe I don't need this separate, explicit state field.*
|
across threads, but maybe I don't need this separate, explicit state field.*
|
||||||
|
|
||||||
> [!IMPORTANT] The `state` data type must support copy semantics on assignment.
|
> [!CAUTION] The `state` data type must support copy semantics on assignment.
|
||||||
> It is possible to pass a `ref` to `state` and/or data structures that include
|
> It is possible to pass a `ref` to `state` and/or data structures that include
|
||||||
> `ref`s, but **you must guarantee they remain valid**, either by allocating
|
> `ref`s, but **you must guarantee they remain valid**, either by allocating
|
||||||
> shared memeory, or (preferably) keeping alive a reference to them that the GC
|
> shared memeory, or (preferably) keeping alive a reference to them that the GC
|
||||||
@@ -261,26 +223,17 @@ behave more intuitively in a multi-threaded environment than
|
|||||||
true in environments where the logging setup code may be separated from the
|
true in environments where the logging setup code may be separated from the
|
||||||
thread-management code (in an HTTP server, for example).
|
thread-management code (in an HTTP server, for example).
|
||||||
|
|
||||||
As described in the [Getting Started](#getting-started) section, you can use
|
The *LogService* object is the main entry point for the logging system and
|
||||||
the `namespaced_logging/autoconfigured` import to use a simplified interface
|
should be initialized on the main thread. The *LogService* contains a reference
|
||||||
that more closely matches the contract of [std/logging][std-logging]. In this
|
to the "source of truth" for logging configuration and is safe to be shared
|
||||||
case all thread and state management is done for you. The only limitation is
|
between all threads.
|
||||||
that you cannot create multiple global *LogService* instances. In practice this
|
|
||||||
is an uncommon need.
|
|
||||||
|
|
||||||
If you do need or want the flexibility to manage the state yourself, import
|
The thread which initializes a *LogService* must also be the longest-living
|
||||||
`namespaced_logging` directly. In this case, the thread which initialized
|
thread that uses that *LogService* instance. If the initializing thread
|
||||||
*LogService* must also be the longest-living thread that uses that *LogService*
|
terminates or the *LogService* object in that thread goes out of scope while
|
||||||
instance. If the initializing thread terminates or the *LogService* object in
|
other threads are still running and using the *LogService*, the global state
|
||||||
that thread goes out of scope while other threads are still running and using
|
may be harvested by the garbage collector, leading to use-after-free errors
|
||||||
the *LogService*, the global state may be harvested by the garbage collector,
|
when other threads attempt to log (likely causing segfaults).
|
||||||
leading to use-after-free errors when other threads attempt to log (likely
|
|
||||||
causing segfaults).
|
|
||||||
|
|
||||||
When managing the state yourself, the *LogService* object is the main entry
|
|
||||||
point for the logging system and should be initialized on the main thread. The
|
|
||||||
*LogService* contains a reference to the "source of truth" for logging
|
|
||||||
configuration and is safe to be shared between all threads.
|
|
||||||
|
|
||||||
Individual threads should use the *threadLocalRef* proc to obtain a
|
Individual threads should use the *threadLocalRef* proc to obtain a
|
||||||
*ThreadLocalLogService* reference that can be used to create *Logger* objects.
|
*ThreadLocalLogService* reference that can be used to create *Logger* objects.
|
||||||
@@ -301,36 +254,11 @@ initialization context is separated from the logging setup code.
|
|||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
The namespaced logging library is built around a thread-safe architecture that
|
The namespaced logging library attempts to balance performance, safety, and
|
||||||
attempts to balance performance, safety, and usability in multithreaded
|
usability in multithreaded environments. The design centers on two key types:
|
||||||
environments. The design centers on two key types (*LogService* and
|
*LogService* and *ThreadLocalLogService*.
|
||||||
*ThreadLocalLogService*) that work together to provide both thread-safe
|
|
||||||
configuration management and efficient logging operations.
|
|
||||||
|
|
||||||
### Core Architecture Components
|
#### LogService (Value Type)
|
||||||
|
|
||||||
#### GlobalLogService (Internal)
|
|
||||||
|
|
||||||
At the heart of the system is the `GlobalLogService`, a heap-allocated object
|
|
||||||
that serves as the single source of truth for logging configuration. This
|
|
||||||
internal type is not exposed to library users but manages:
|
|
||||||
|
|
||||||
- **Shared configuration state**: Appenders, thresholds, and root logging level
|
|
||||||
- **Synchronization primitives**: Locks and atomic variables for thread
|
|
||||||
coordination
|
|
||||||
- **Background I/O threads**: Dedicated writer threads for console and file
|
|
||||||
output
|
|
||||||
- **Configuration versioning**: Atomic version numbers for efficient change
|
|
||||||
detection
|
|
||||||
|
|
||||||
The `GlobalLogService` ensures that configuration changes are safely propagated
|
|
||||||
across all threads while maintaining high performance for logging operations.
|
|
||||||
|
|
||||||
#### LogService vs ThreadLocalLogService
|
|
||||||
|
|
||||||
The library exposes two distinct types for different usage patterns:
|
|
||||||
|
|
||||||
##### LogService (Value Type)
|
|
||||||
```nim
|
```nim
|
||||||
type LogService* = object
|
type LogService* = object
|
||||||
configVersion: int
|
configVersion: int
|
||||||
@@ -353,25 +281,42 @@ The *LogService* object is intended to support uses cases such as:
|
|||||||
> The *LogService* object is the object that is intended to be shared across
|
> The *LogService* object is the object that is intended to be shared across
|
||||||
> threads.
|
> threads.
|
||||||
|
|
||||||
##### ThreadLocalLogService (Reference Type)
|
#### ThreadLocalLogService (Reference Type)
|
||||||
```nim
|
```nim
|
||||||
type ThreadLocalLogService* = ref LogService
|
type ThreadLocalLogService* = ref LogService
|
||||||
```
|
```
|
||||||
|
|
||||||
The *ThreadLocalLogService* is a reference to a thread-local copy of a
|
*ThreadLocalLogService* is a reference to a thread-local copy of a *LogService*
|
||||||
*LogService* and can be obtained via *threadLocalRef*. We purposefully use
|
and can be obtained via *threadLocalRef*. We purposefully use reference
|
||||||
reference semantics within the context of a thread so that *Logger* objects
|
semantics within the context of a thread so that *Logger* objects created
|
||||||
created within the same thread context share the same *ThreadLocalLogService*
|
within the same thread context share the same *ThreadLocalLogService*
|
||||||
reference, avoiding the need to synchronize every *Logger* individually.
|
reference, avoiding the need to synchronize every *Logger* individually.
|
||||||
|
|
||||||
The *ThreadLocalLogService* is the object that users are expected to interact
|
*ThreadLocalLogService* is the object that users are expected to interact with
|
||||||
with during regular operation and support both the configuration functions of
|
during regular operation and support both the configuration functions of
|
||||||
*LogService* and the creation of *Logger* objects.
|
*LogService* and the creation of *Logger* objects.
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> *ThreadLocalLogService* objects should **never** be shared outside the
|
> *ThreadLocalLogService* objects should **never** be shared outside the
|
||||||
> context of the thread in which they were initialized.
|
> context of the thread in which they were initialized.
|
||||||
|
|
||||||
|
#### GlobalLogService (Internal)
|
||||||
|
|
||||||
|
Under the hood *LogService* holds a reference to a *GlobalLogService*, a
|
||||||
|
heap-allocated object that serves as the single source of truth for logging
|
||||||
|
configuration. This internal type is not exposed to library users but manages:
|
||||||
|
|
||||||
|
- **Shared configuration state**: Appenders, thresholds, and root logging level
|
||||||
|
- **Synchronization primitives**: Locks and atomic variables for thread
|
||||||
|
coordination
|
||||||
|
- **Background I/O threads**: Dedicated writer threads for console and file
|
||||||
|
output
|
||||||
|
- **Configuration versioning**: Atomic version numbers for efficient change
|
||||||
|
detection
|
||||||
|
|
||||||
|
The `GlobalLogService` ensures that configuration changes are safely propagated
|
||||||
|
across all threads while maintaining high performance for logging operations.
|
||||||
|
|
||||||
### Thread Safety Model
|
### Thread Safety Model
|
||||||
|
|
||||||
#### Safe Cross-Thread Pattern
|
#### Safe Cross-Thread Pattern
|
||||||
@@ -417,14 +362,16 @@ proc ensureFreshness*(ls: var LogService) =
|
|||||||
# Sync state...
|
# Sync state...
|
||||||
```
|
```
|
||||||
|
|
||||||
This design ensures that:
|
Goals/Motivation:
|
||||||
- **Hot path is fast**: Most logging operations skip expensive synchronization
|
- Most logging operations skip expensive synchronization so the hot path is
|
||||||
- **Changes propagate automatically**: All threads see configuration updates
|
fast.
|
||||||
- **Minimal lock contention**: Locks only acquired when configuration changes
|
- Propogate changes automatically so all threads see configuration updates.
|
||||||
|
- Minimize lock contention by only acquiring when configuration changes
|
||||||
|
|
||||||
#### Thread-Local Caching
|
#### Thread-Local Caching
|
||||||
|
|
||||||
Each thread maintains its own copy of the logging configuration:
|
Each thread maintains its own copy of the logging configuration in
|
||||||
|
*ThreadLocalLogService*:
|
||||||
|
|
||||||
- **Appenders**: Thread-local copies created via `clone()` method
|
- **Appenders**: Thread-local copies created via `clone()` method
|
||||||
- **Thresholds**: Complete copy of namespace-to-level mappings
|
- **Thresholds**: Complete copy of namespace-to-level mappings
|
||||||
@@ -492,6 +439,7 @@ logService.setErrorHandler(silentErrorHandler)
|
|||||||
### Best Practices
|
### Best Practices
|
||||||
|
|
||||||
#### Provide Fallbacks
|
#### Provide Fallbacks
|
||||||
|
|
||||||
```nim
|
```nim
|
||||||
proc robustErrorHandler(err: ref Exception, msg: string) {.gcsafe, nimcall.} =
|
proc robustErrorHandler(err: ref Exception, msg: string) {.gcsafe, nimcall.} =
|
||||||
# Primary: Send to monitoring system
|
# Primary: Send to monitoring system
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
# Package
|
# Package
|
||||||
|
|
||||||
version = "1.1.0"
|
version = "2.1.2"
|
||||||
author = "Jonathan Bernard"
|
author = "Jonathan Bernard"
|
||||||
description = "Wrapper around std/logging to provide namespaced logging."
|
description = "Wrapper around std/logging to provide namespaced logging."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -13,3 +13,7 @@ requires @["nim >= 2.2.0", "zero_functional"]
|
|||||||
|
|
||||||
# from https://git.jdb-software.com/jdb/nim-packages
|
# from https://git.jdb-software.com/jdb/nim-packages
|
||||||
requires "timeutils"
|
requires "timeutils"
|
||||||
|
|
||||||
|
task test, "Run unittests for the package.":
|
||||||
|
exec "nimble c src/namespaced_logging.nim"
|
||||||
|
exec "src/namespaced_logging.out"
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
|||||||
import std/[json, options]
|
|
||||||
from logging import Level
|
|
||||||
import ../namespaced_logging
|
|
||||||
|
|
||||||
export
|
|
||||||
# Types
|
|
||||||
Level,
|
|
||||||
Logger,
|
|
||||||
LogAppender,
|
|
||||||
LogMessage,
|
|
||||||
ConsoleLogAppender,
|
|
||||||
CustomLogAppender,
|
|
||||||
CustomLogAppenderFunction,
|
|
||||||
FileLogAppender,
|
|
||||||
|
|
||||||
# Procs/Funcs
|
|
||||||
`%`,
|
|
||||||
initConsoleLogAppender,
|
|
||||||
initCustomLogAppender,
|
|
||||||
initFileLogAppender,
|
|
||||||
formatJsonStructuredLog
|
|
||||||
|
|
||||||
var globalLogServiceRef: ThreadLocalLogService = new(LogService)
|
|
||||||
globalLogServiceRef[] = initLogService()
|
|
||||||
|
|
||||||
var threadLocalLogServiceRef {.threadvar.}: ThreadLocalLogService
|
|
||||||
var defaultLogger {.threadvar.}: Option[Logger]
|
|
||||||
|
|
||||||
|
|
||||||
proc getThreadLocalLogServiceRef(): ThreadLocalLogService {.inline.} =
|
|
||||||
if threadLocalLogServiceRef.isNil:
|
|
||||||
threadLocalLogServiceRef = new(LogService)
|
|
||||||
threadLocalLogServiceRef[] = globalLogServiceRef[]
|
|
||||||
|
|
||||||
return threadLocalLogServiceRef
|
|
||||||
|
|
||||||
proc getDefaultLogger(): Logger {.inline.} =
|
|
||||||
|
|
||||||
if defaultLogger.isNone:
|
|
||||||
defaultLogger = some(getThreadLocalLogServiceRef().getLogger(""))
|
|
||||||
|
|
||||||
return defaultLogger.get
|
|
||||||
|
|
||||||
|
|
||||||
proc useForAutoconfiguredLogging*(ls: LogService) =
|
|
||||||
globalLogServiceRef[] = ls
|
|
||||||
|
|
||||||
|
|
||||||
proc setRootLoggingThreshold*(lvl: Level) =
|
|
||||||
setRootThreshold(getThreadLocalLogServiceRef(), lvl)
|
|
||||||
|
|
||||||
|
|
||||||
proc setLoggingThreshold*(scope: string, lvl: Level) =
|
|
||||||
setThreshold(getThreadLocalLogServiceRef(), scope, lvl)
|
|
||||||
|
|
||||||
|
|
||||||
proc addLogAppender*(appender: LogAppender) =
|
|
||||||
addAppender(getThreadLocalLogServiceRef(), appender)
|
|
||||||
|
|
||||||
|
|
||||||
proc getLogger*(scope: string, lvl: Option[Level] = none[Level]()): Logger =
|
|
||||||
getLogger(getThreadLocalLogServiceRef(), scope, lvl)
|
|
||||||
|
|
||||||
|
|
||||||
proc log*(lvl: Level, msg: string) = getDefaultLogger().log(lvl, msg)
|
|
||||||
proc log*(lvl: Level, msg: JsonNode) = getDefaultLogger().log(lvl, msg)
|
|
||||||
|
|
||||||
proc log*(lvl: Level, error: ref Exception, msg: string) =
|
|
||||||
getDefaultLogger().log(lvl, error, msg)
|
|
||||||
|
|
||||||
template debug*[T](msg: T) = log(lvlDebug, msg)
|
|
||||||
template info*[T](msg: T) = log(lvlInfo, msg)
|
|
||||||
template notice*[T](msg: T) = log(lvlNotice, msg)
|
|
||||||
template warn*[T](msg: T) = log(lvlWarn, msg)
|
|
||||||
template error*[T](msg: T) = log(lvlError, msg)
|
|
||||||
template error*(error: ref Exception, msg: string) = log(lvlError, error, msg)
|
|
||||||
template fatal*[T](msg: T) = log(lvlFatal, msg)
|
|
||||||
template fatal*(error: ref Exception, msg: string) = log(lvlFatal, error, msg)
|
|
||||||
|
|
||||||
when isMainModule:
|
|
||||||
import std/unittest
|
|
||||||
import ./testutil
|
|
||||||
|
|
||||||
suite "Autoconfigured Logging":
|
|
||||||
setup:
|
|
||||||
globalLogServiceRef[] = initLogService()
|
|
||||||
let loggedMessages = initLoggedMessages()
|
|
||||||
let testAppender = initTestLogAppender(loggedMessages)
|
|
||||||
|
|
||||||
test "simple no-config logging":
|
|
||||||
addLogAppender(testAppender)
|
|
||||||
info("test message")
|
|
||||||
|
|
||||||
let lm = loggedMessages.get()
|
|
||||||
check:
|
|
||||||
lm.len == 1
|
|
||||||
lm[0].level == lvlInfo
|
|
||||||
lm[0].message == "test message"
|
|
Reference in New Issue
Block a user