183 lines
7.5 KiB
Markdown
Raw Permalink Normal View History

# 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