6 Commits
0.3.0 ... 2.0.0

Author SHA1 Message Date
f80e5807db refactor: Complete rewrite of logging system with thread-safe architecture
This commit represents a complete architectural overhaul of the namespaced
logging library, transitioning from a shared-memory approach to a robust
thread-safe design with proper synchronization primitives.

- **NEW**: internal `GlobalLogService` with atomic configuration versioning
- **NEW**: Thread-local configuration caching with freshness checking
- **NEW**: Separate `LogService` (copyable) and `ThreadLocalLogService` (ref)
- **REMOVED**: Manual thread state reloading in favor of automatic freshness

- All shared state protected by locks and atomics
- Configuration changes use atomic version numbers for efficient sync
- Proper cleanup with `=destroy` implementing graceful shutdown
- Thread-safe appender cloning via `clone()` method pattern

- **NEW**: Dedicated writer threads for console and file output
- **NEW**: Channel-based message passing to writer threads
- **NEW**: Batched file I/O with optimized write patterns
- **NEW**: Graceful thread shutdown on service destruction

- **NEW**: Configurable error handling with `ErrorHandlerFunc`
- **NEW**: `defaultErrorHandlerFunc` with stderr fallback
- **NEW**: Thread-safe error reporting with separate lock
- **NEW**: Robust error recovery in all I/O operations

- **NEW**: `autoconfigured` module for zero-config usage
- **NEW**: `formatSimpleTextLog` as default formatter
- **NEW**: Optional logger support for ergonomic usage
- **NEW**: Generic `CustomLogAppender[T]` with state parameter
- **NEW**: `FileLogAppender` with proper multithreaded file I/O
- **BREAKING**: Logger `name` field renamed to `scope`
- **BREAKING**: Configuration methods renamed (e.g., `setRootLevel` → `setRootThreshold`)

- **NEW**: Comprehensive test suite with 20+ test cases
- **NEW**: `testutil` module with thread-safe test infrastructure
- **NEW**: Cross-thread synchronization testing
- **NEW**: File I/O testing with temporary files
- **REMOVED**: Old test suite replaced with more comprehensive version

- Atomic version checking prevents unnecessary config copies
- Writer threads use efficient polling with 100ms idle sleep
- File writer batches messages and optimizes file operations
- Thread-local caching reduces lock contention

1. **API Changes**:
   - `LogService` returned by `iniLogService` is fundamentally different
   - `threadLocalRef()` required for thread-local operations
   - `reloadThreadState()` removed (automatic freshness)
   - Logger field `name` → `scope`

2. **Configuration**:
   - `setRootLevel()` → `setRootThreshold()`
   - `setThreshold()` API simplified
   - `clearAppenders()` removed

3. **Appenders**:
   - `initThreadCopy()` → `clone()`
   - `appendLogMessage()` signature changed
   - Custom appenders now generic with state parameter

```nim
let ls = initLogService()
ls.addAppender(initConsoleLogAppender())
reloadThreadState(ls)
let logger = ls.getLogger("app")
logger.info("Hello world")
```

```nim
let ls = initLogService()
let tlls = threadLocalRef(ls)
tlls.addAppender(initConsoleLogAppender())
let logger = tlls.getLogger("app")
logger.info("Hello world")
```
```nim
import namespaced_logging/autoconfigured
addLogAppender(initConsoleLogAppender())
info("Hello world")
```
2025-07-06 01:03:35 -05:00
e0dba8125c Add CustomLogAppender to allow extensible log appenders. 2025-05-19 18:05:06 -05:00
0eb0d33573 Expose appender config, default ConsoleLogAppender threshold to ALL. 2025-02-04 17:56:01 -06:00
92c2dec54d Major refactor to better support multi-threading.
See README for details.
2025-01-05 06:25:35 -06:00
ab20a30434 Swallow impossible exception. 2023-02-25 14:45:05 -07:00
f3dbac0896 Fix default prefix format. 2022-11-03 15:14:43 -05:00
6 changed files with 1448 additions and 51 deletions

3
.gitignore vendored
View File

@ -1,2 +1,5 @@
.*.sw?
nimcache/
tests/tests
src/namespaced_logging.out
src/namespaced_logging/autoconfigured

182
README.md Normal file
View File

@ -0,0 +1,182 @@
# Namespaced Logging for Nim
`namespaced_logging` provides a logging framework similar to [log4j][] or
[logback][] for Nim. It has three main motivating features:
- Hierarchical, namespaced logging
- Safe and straightforward to use in multi-threaded applications.
- Native support for structured logging (old-style string logging is also
supported).
## Getting Started
Install the package from nimble:
```bash
nimble install namespaced_logging
```
Then, in your application, you can use the logging system like so:
```nim
import namespaced_logging
# On the main thread
let logService = initLogService()
logService.addAppender(initConsoleAppender(LogLevel.INFO))
# On any thread, including the main thread
let logger = logService.getLogger("app/service/example")
logger.info("Log from the example service")
# Only get logs at the WARN or higher level from the database module
let logger = logService.getLogger("app/database", threshold = some(Level.lvlWarn))
logger.error("Database connection failed")
```
## Loggers and Appenders
The logging system is composed of two main components: loggers and appenders.
Loggers are used to create log events, which are then passed to the appenders.
Appenders take log events and write them to some destination, such as the
console, a file, or a network socket. Appenders also have a logging level
threshold, which determines which log events are acted upon by the appender,
and, optionally, a namespace filter, which determines from which loggers the
appender accepts log events.
### Heirarchical Logging and Namespaces
Loggers are organized hierarchically, with the hierarchy defined by the logger
name. A logger with the name `app/service/example` is a child of the logger
with the name `app/service`. By default, appenders accept log events from all
loggers, but this can be restricted by setting a namespace filter on the
appender. An appender with a namespace set will accept log events from all
loggers with names that start with the namespace. For example, an appender with
the namespace `app` will accept log events from the loggers `app`,
`app/service`, and `app/service/example`, but not from `api/service`.
The other impact of the logger heirarchy is in the effective logging level of
the logger. Any logger can have an explicit logging level set, but if it does
not, the effective logging level is inherited from ancestor loggers upwards in
the logger heirarchy. This pattern is explained in detail in the [logback
documentation][effective logging level] and applies in the same manner to
loggers in this library.
## Notes on Use in Multi-Threaded Applications
The loggers and appenders in this library are thread-safe and behaves more
intuitively in a multi-threaded environment than `std/logging`, particularly in
environments where the logging setup code may be separated from the
thread-management code (in an HTTP server, for example).
The *LogService* object is the main entry point for the logging system and
should be initialized on the main thread. The *LogService* contains the "source
of truth" for logging configuration and is shared between all threads.
Internally all access to the *LogService* is protected by a mutex.
Logging can be very noisy and if the *LogService* needed to be consulted for
every log event, it could easily become a performance bottleneck. To avoid
this, the *getLogger* procedure makes a thread-local copy of the logging system
configuration (loggers defined and appenders attached).
**Note** that this means that the thread-local cache of the logging system
configuration can become stale if the logging system configuration is changed
after the thread-local copy is made (if another appender is added, for
example). This is a trade-off to avoid the performance penalty of consulting
the *LogService* for every log event.
This thread-local caching mechanism is the primary advantage of this logging
system over `std/logging` in a multi-threaded environment as it means that
the logging system itself is responsible for making sure appenders are
configured for every thread where loggers are used, even if the thread
initialization context is separated from the logging setup code.
If you find yourself needing to change the logging configuration after the
logging system has been initialized, the *reloadThreadState* procedure can be
used to update the thread-local cache of the logging system configuration, but
it must be called on the thread you wish to update.
As a final note, the advice to initialize the *LogService* on the main thread
is primarily to simplify the configuration of the logging service and avoid the
need to manually reload caches on individual threads. A *LogService* reference
is required to call *getLogger*, but it can be created on any thread.
## Custom Appender Implementations
Due to the thread-safety of the logging system, there are a few additional
considerations when implementing custom appenders. The *LogAppender* abstract
class is the base class for all appenders. To implement a custom appender, two
methods must be implemented:
### `appendLogMessage`
```nim
method appendLogMessage*(appender: CustomLogAppender, msg: LogMessage): void {.base, gcsafe.}
```
This is the primary appender implementation that takes a LogMessage and
writes it to the appender's destination. As the signature suggests, the
implementation must be GC-safe. As a multi-method, the *CustomLogAppender* type
should be replaced by the actual name of your custom appender.
Because the *LogAppender* uses multi-methods for dynamic dispatch, the
custom appender class must also be a `ref` type.
### `initThreadCopy`
```nim
method initThreadCopy*(app: LogAppender): LogAppender {.base, gcsafe.}
```
This method is used to create a thread-local copy of the appender. It is called
by the *reloadThreadState* procedure to update the thread-local cache of the
logging system configuration. The implementation will be passed the appender
instance that was provided to the *addAppender* procedure and must return a
thread-local copy of that appender.
The `initThreadCopy` implementations for the built-in *ConsoleLogAppender* and
*FileLogAppender* provide simple examples of how to implement this method by
simply copying state into the local thread, but this method can also be used
to perform any other thread-specific initialization that may be required for
the appender implementation.
### Example Custom Appender
The following defines a simple custom appender that writes log messages to a
database table. It uses the [waterpark][] connection pooling library to manage
database connections as waterpark is also thread-safe and makes implementation
straight-forward.
```nim
import db_connectors/db_postgres
import namespaced_logging, waterpark, waterpark/db_postgres
type DbLogAppender = ref object of LogAppender
dbPool: PostgresPool
let dbPool: PostgresPool = newPostgresPool(10, "", "", "", connectionString)
method initThreadCopy*(app: LogAppender): LogAppender =
result = DbLogAppender(dbPool: dbPool) # copy semantics as PostgresPool is an object
method appendLogMessage*(appender: DbLogAppender, msg: LogMessage): void {gcsafe.} =
appender.withConnection conn:
conn.insert(
"INSERT INTO log_events " &
" (level, scope, message, error, timestamp, custom_fields) " &
"VALUES " &
" (?, ?, ?, ?, ?, ?)",
msg.level,
msg.scope,
msg.message,
if msg.error.isSome: msg.error.msg
else: "",
msg.timestamp,
msg.additionalData)
```
[log4j]: https://logging.apache.org/log4j/2.x/
[logback]: https://logback.qos.ch/
[effective logging level]: https://logback.qos.ch/manual/architecture.html#effectiveLevel

View File

@ -1,6 +1,6 @@
# Package
version = "0.3.0"
version = "1.1.0"
author = "Jonathan Bernard"
description = "Wrapper around std/logging to provide namespaced logging."
license = "MIT"
@ -9,4 +9,7 @@ srcDir = "src"
# Dependencies
requires "nim >= 1.0.4"
requires @["nim >= 2.2.0", "zero_functional"]
# from https://git.jdb-software.com/jdb/nim-packages
requires "timeutils"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,98 @@
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"

View File

@ -0,0 +1,51 @@
import std/[locks, sequtils, syncio, os, times]
from logging import Level
from ../namespaced_logging import CustomLogAppender, initCustomLogAppender, LogMessage
type
LoggedMessages* = ref object
messages*: seq[LogMessage]
lock: Lock
proc initLoggedMessages*(): LoggedMessages =
result = LoggedMessages(messages: @[])
initLock(result.lock)
proc add*(lm: LoggedMessages, msg: LogMessage) =
withLock lm.lock: lm.messages.add(msg)
proc clear*(lm: LoggedMessages) =
withLock lm.lock: lm.messages = @[]
proc get*(lm: LoggedMessages): seq[LogMessage] =
withLock lm.lock: return lm.messages.mapIt(it)
proc testLogAppenderProc(state: LoggedMessages, msg: LogMessage) {.gcsafe, noconv.} =
state.add(msg)
proc initTestLogAppender*(
lm: LoggedMessages,
namespace = "",
threshold = lvlAll): CustomLogAppender[LoggedMessages] =
initCustomLogAppender(
state = lm,
doLogMessage = testLogAppenderProc,
namespace = namespace,
threshold = threshold)
proc waitForFileContent*(
path: string,
expectedLines: int,
timeoutMs: int = 1000): seq[string] =
let startTime = getTime()
while (getTime() - startTime).inMilliseconds < timeoutMs:
if fileExists(path):
result = readLines(path)
if result.len >= expectedLines: break
sleep(10)