183 lines
7.5 KiB
Markdown
183 lines
7.5 KiB
Markdown
|
# 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
|