diff --git a/README.md b/README.md index 4f860b2..12ec9c7 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,89 @@ # esm-logging -> This README is a stub. Working on it. Currently stabilizing the build - environment after that I'll make it nice around here. +A quasi-port of the Python standard library +[logging](https://docs.python.org/3/library/logging.html) module to +ECMAScript. Browser-compatible, zero dependencies. -A quasi-port of the Python standard library logging module to ECMAScript. +## Why? -# Why? - -First of, because logging is important. It is important for debugging purposes, -leading to faster and more resilient development, for traceability leading to -better security. Most logging libraries I've discovered didn't satisfy me, -introduced weird concepts and all in all just weren't great. Other programming -language ecosystems offer way nicer logging facilities. Take Rust for example, -or... Python! Python has PEP, giving it a very structured approach towards +Logging is important. It is important for debugging purposes, leading to +faster and more resilient development, for traceability leading to better +security. Most logging libraries I've discovered didn't satisfy me, introduced +weird concepts and all in all just weren't great. Other programming language +ecosystems offer way nicer logging facilities. Take Rust for example, or... +Python! Python has PEP, giving it a very structured approach towards implementing new features and that's also how its logging facilities came to be -([PEP 282](https://peps.python.org/pep-0282/)). Python's logging facilities are -implemented by the [logging]() module, which is part of the standard library and -has been since 2002. It was originally authored by Vinay Sajip +([PEP 282](https://peps.python.org/pep-0282/)). Python's logging facilities +are implemented by the [logging](https://docs.python.org/3/library/logging.html) +module, which is part of the standard library and has been since 2002. It was +originally authored by Vinay Sajip. -# Roadmap +## Installation -- do a quasi-port of the logging module with minimal amount of adaption -- add documentation -- add support for asynchronous calls -- implement Open Cybersecurity Framework (OCSF) formatter -- implement (Browser) local storage handler as a replacement for file handler +```bash +npm install @administratrix/esm-logging +``` -# Usage +## Quick start -For the time being, please check out my [CI -service](https://bitbucket.org/byteb4rb1e/esm-logging/pipelines), for an idea on -how to build this. +```javascript +import * as logging from '@administratrix/esm-logging'; + +// one-shot configuration: sets up a console handler on the root logger +logging.config.basicConfig({ level: logging.log_level.INFO }); + +// create a logger for this module +const logger = logging.manager.MANAGER.getLogger('myapp'); + +logger.info('Application started'); +logger.warning('Something looks off'); +logger.error('Something went wrong'); +``` + +## Concepts + +The logging system is built around four core components: + +- **Loggers** expose the interface that application code uses directly. +- **Handlers** send log records to the appropriate destination (console, + stderr, custom writable streams). +- **Formatters** control the layout of log records in the final output. +- **Filters** provide fine-grained control over which records to output. + +Loggers are organized in a dot-separated hierarchy. A logger named `app.db` +is a child of the logger named `app`. Log records propagate up the hierarchy, +so a handler attached to `app` will also receive records from `app.db`. + +## Log levels + +| Constant | Value | Purpose | +|------------|-------|------------------------------------------| +| `CRITICAL` | 50 | A serious error, the program may not continue | +| `ERROR` | 40 | An error that prevented some operation | +| `WARNING` | 30 | Something unexpected, but the software still works | +| `INFO` | 20 | Confirmation that things work as expected | +| `DEBUG` | 10 | Detailed diagnostic information | +| `NOTSET` | 0 | All messages are processed | + +## Documentation + +See [docs/](docs/README.md) for the full user guide and +[docs/logging-cookbook.md](docs/logging-cookbook.md) for recipes and patterns. + +API reference can be generated with TypeDoc: + +```bash +npm run build/doc +``` + +## Roadmap + +- [x] quasi-port of the logging module with minimal adaptation +- [x] add documentation +- [ ] add support for asynchronous calls +- [ ] implement Open Cybersecurity Schema Framework (OCSF) formatter +- [ ] implement browser local storage handler as a replacement for file handler + +## License + +UNLICENSED diff --git a/docs/README.md b/docs/README.md index 776f875..d7eff57 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1 +1,333 @@ -The doc/README.md \ No newline at end of file +# esm-logging user guide + +This guide covers the `@administratrix/esm-logging` library, a quasi-port of +Python's `logging` module for ECMAScript. It targets browser environments and +provides hierarchical, configurable logging with formatters, handlers, and +filters. + +## Table of contents + +- [Basic usage](#basic-usage) +- [Loggers](#loggers) +- [Handlers](#handlers) +- [Formatters](#formatters) +- [Filters](#filters) +- [Configuration](#configuration) +- [Log record attributes](#log-record-attributes) + +## Basic usage + +The simplest way to start logging is with `basicConfig`: + +```javascript +import * as logging from '@administratrix/esm-logging'; + +logging.config.basicConfig({ + level: logging.log_level.DEBUG, + format: '%(levelname)s:%(name)s:%(message)s', +}); + +const logger = logging.manager.MANAGER.getLogger('myapp'); +logger.info('Hello, world'); +// output: INFO:myapp:Hello, world +``` + +`basicConfig` creates a `StreamHandler` writing to stderr (via +`console.error`) and attaches it to the root logger. It is a one-shot +function: calling it again has no effect unless you pass `force: true`. + +## Loggers + +Loggers are the entry point for emitting log records. They are organized in a +dot-separated hierarchy managed by a singleton `Manager`. + +### Creating loggers + +```javascript +const logger = logging.manager.MANAGER.getLogger('myapp'); +const childLogger = logging.manager.MANAGER.getLogger('myapp.db'); +``` + +Calling `getLogger` with the same scope always returns the same logger +instance. The hierarchy is established automatically: `myapp.db` is a child of +`myapp`. + +### Setting levels + +```javascript +logger.setLevel(logging.log_level.WARNING); +``` + +A logger only processes messages at or above its effective level. The +effective level is determined by walking up the parent chain until a logger +with a non-zero level is found. The root logger defaults to `WARNING`. + +### Logging methods + +Each level has a corresponding method: + +```javascript +logger.debug('Detailed diagnostic info'); +logger.info('Things are working'); +logger.warning('Something unexpected'); +logger.error('An operation failed'); +logger.critical('System is in trouble'); +``` + +### Propagation + +By default, log records propagate up the hierarchy. A record emitted by +`myapp.db` will be handled by handlers on `myapp.db`, then `myapp`, then the +root logger. This means you typically only need to configure handlers on the +root logger or on high-level loggers. + +### Checking if a level is enabled + +```javascript +if (logger.isEnabledFor(logging.log_level.DEBUG)) { + logger.debug('Expensive computation: ' + computeDebugInfo()); +} +``` + +This avoids the cost of building the message string when the level would be +filtered out anyway. + +## Handlers + +Handlers determine where log records go. A logger can have multiple handlers, +and each handler can have its own level and formatter. + +### Available handlers + +#### StreamHandler + +Writes formatted output to a `Writable` stream. Defaults to stderr (via +`console.error`). + +```javascript +import { StreamHandler } from '@administratrix/esm-logging/src/handler'; +import { ConsoleWritable } from '@administratrix/esm-logging/src/helper/stream'; + +const handler = new StreamHandler(new ConsoleWritable()); +logger.addHandler(handler); +``` + +#### ConsoleHandler + +Maps log levels to the appropriate browser console method: + +- `ERROR` and `CRITICAL` use `console.error` +- `WARNING` uses `console.warn` +- `DEBUG` and `INFO` use `console.log` + +```javascript +import { ConsoleHandler } from '@administratrix/esm-logging/src/handler'; + +const handler = new ConsoleHandler(); +handler.level = logging.log_level.DEBUG; +logger.addHandler(handler); +``` + +#### StderrHandler + +Always writes to `console.error`, regardless of level. Useful when you want +all output on stderr. + +```javascript +import { StderrHandler } from '@administratrix/esm-logging/src/handler'; + +const handler = new StderrHandler(logging.log_level.WARNING); +logger.addHandler(handler); +``` + +#### FileHandler + +Not available in browser environments. Throws `NotImplementedError` on +construction. Use `ConsoleHandler` or a storage-backed handler instead. + +### Handler methods + +```javascript +handler.level = logging.log_level.INFO; // only handle INFO and above +handler.formatter = myFormatter; // set a custom formatter +handler.close(); // release resources +``` + +### Custom handlers + +Subclass `Handler` and implement `emit(record)`: + +```javascript +import { Handler } from '@administratrix/esm-logging/src/handler'; + +class ArrayHandler extends Handler { + constructor() { + super(); + this.records = []; + } + + emit(record) { + this.records.push(this.format(record)); + } +} +``` + +## Formatters + +Formatters control how log records are rendered as strings. The default format +is `%(message)s` (just the message). The basic format used by `basicConfig` is +`%(levelname)s:%(name)s:%(message)s`. + +### Creating a formatter + +```javascript +import { Formatter } from '@administratrix/esm-logging/src/formatter'; + +const formatter = new Formatter({ + fmt: '%(asctime)s - %(levelname)s - %(name)s - %(message)s', + datefmt: '%Y-%m-%d %H:%M:%S', +}); + +handler.formatter = formatter; +``` + +### Format string placeholders + +Formatters use `%`-style substitution. Available placeholders correspond to +[log record attributes](#log-record-attributes): + +``` +%(name)s Logger scope name +%(levelno)d Numeric log level +%(levelname)s Text log level (DEBUG, INFO, etc.) +%(message)s The formatted message +%(asctime)s Human-readable timestamp +%(created)f Milliseconds since epoch (Date.now()) +``` + +### Date formatting + +The `datefmt` option accepts strftime-style tokens: + +| Token | Meaning | Example | +|-------|-----------------|---------| +| `%Y` | Four-digit year | 2026 | +| `%m` | Zero-padded month | 03 | +| `%d` | Zero-padded day | 14 | +| `%H` | Hour (24h) | 09 | +| `%M` | Minute | 05 | +| `%S` | Second | 30 | + +If `datefmt` is omitted, an ISO 8601-like format is used: +`2026-03-14 09:05:30.123`. + +## Filters + +Filters provide fine-grained control over which records get processed. They +can be attached to loggers or handlers. + +### Scope-based filtering + +A `Filter` initialized with a scope name only allows records from that scope +and its children: + +```javascript +import { Filter } from '@administratrix/esm-logging/src/filter'; + +const filter = new Filter('myapp.db'); +handler.addFilter(filter); +// only records from 'myapp.db' and 'myapp.db.*' will pass +``` + +A filter initialized with an empty string allows all records. + +### Custom filters + +Any object with a `filter(record)` method can be used: + +```javascript +handler.addFilter({ + filter(record) { + return record.levelno >= logging.log_level.WARNING; + } +}); +``` + +## Configuration + +### basicConfig + +`basicConfig` is a convenience function for simple, one-shot configuration of +the root logger: + +```javascript +logging.config.basicConfig({ + level: logging.log_level.DEBUG, + format: '%(asctime)s %(levelname)s %(message)s', + datefmt: '%Y-%m-%d %H:%M:%S', +}); +``` + +#### Options + +| Option | Type | Description | +|------------|------------|--------------------------------------------| +| `level` | `number` | Root logger level | +| `format` | `string` | Format string for the handler's formatter | +| `datefmt` | `string` | Date format string | +| `style` | `string` | Format style (`'%'` only, currently) | +| `handlers` | `Handler[]`| Pre-built handlers to attach | +| `stream` | `Writable` | Stream for the default StreamHandler | +| `force` | `boolean` | Remove existing handlers first | + +`stream` and `handlers` are mutually exclusive. + +### Manual configuration + +For more control, configure loggers and handlers directly: + +```javascript +const root = logging.manager.MANAGER.root; +const handler = new ConsoleHandler(); +handler.level = logging.log_level.DEBUG; +handler.formatter = new Formatter({ + fmt: '%(asctime)s [%(levelname)s] %(name)s: %(message)s', +}); +root.addHandler(handler); +root.setLevel(logging.log_level.DEBUG); +``` + +## Log record attributes + +A `LogRecord` carries the following attributes: + +| Attribute | Type | Description | +|-------------|----------|----------------------------------------| +| `scope` | `string` | Logger name that created the record | +| `name` | `string` | Same as `scope` | +| `levelno` | `number` | Numeric level (10, 20, 30, 40, 50) | +| `levelname` | `string` | Text level (`DEBUG`, `INFO`, etc.) | +| `msg` | `string` | The raw message template | +| `args` | `any[]` | Substitution arguments for `%s` in msg | +| `message` | `string` | The formatted message (set by formatter) | +| `created` | `number` | Milliseconds since Unix epoch | +| `asctime` | `string` | Formatted timestamp (set by formatter) | + +The `getMessage()` method on `LogRecord` performs `%s` argument substitution +on `msg` using `args`. + +## Module structure + +The library is organized into submodules, all re-exported from the main entry +point: + +| Import path | Contents | +|---------------|----------------------------------------------| +| `config` | `basicConfig()` | +| `filter` | `Filter`, `Filterer` | +| `formatter` | `Formatter`, `STYLES`, `DEFAULT_FORMATTER` | +| `handler` | `Handler`, `StreamHandler`, `ConsoleHandler`, `StderrHandler`, `FileHandler` | +| `log_level` | Level constants, `getLevelName()`, `checkLevel()` | +| `log_record` | `LogRecord`, factory functions | +| `logger` | `Logger`, `RootLogger`, `ROOT` | +| `manager` | `Manager`, `MANAGER` | diff --git a/docs/logging-cookbook.md b/docs/logging-cookbook.md index a0e5d9c..7d4e727 100644 --- a/docs/logging-cookbook.md +++ b/docs/logging-cookbook.md @@ -1,37 +1,216 @@ -# Using logging in multiple modules +# Logging cookbook -Multiple calls to `logging.getLogger('someLogger')` return a reference to the -same logger object. This is true not only within the same module, but also -across modules as long as it is in the same Python interpreter process. It is -true for references to the same object; additionally, application code can -define and configure a parent logger in one module and create (but not -configure) a child logger in a separate module, and all logger calls to the -child will pass up to the parent. Here is a main module: +Recipes and patterns for common logging tasks with `@administratrix/esm-logging`. -``javascript -import * as logging from 'eslib/logging'; -import * as my_module from './my_module'; +## Using logging across multiple modules -// create logger with 'spam_application' -var logger = logging.getLogger('spam_application'); -logger.setLevel(logging.DEBUG); +`getLogger` returns the same logger instance for a given scope name across +your entire application. Configure handlers on a parent logger and create +child loggers in each module: -// create file handler which logs even debug messages -var fh = logging.FileHandler('spam.log') -fh.setLevel(logging.DEBUG); +```javascript +// main.js +import * as logging from '@administratrix/esm-logging'; +import { ConsoleHandler } from '@administratrix/esm-logging/src/handler'; +import { Formatter } from '@administratrix/esm-logging/src/formatter'; +import { doWork } from './worker.js'; -// create console handler with a higher log level -var ch = logging.StreamHandler(); -ch.setLevel(logging.ERROR); +const logger = logging.manager.MANAGER.getLogger('myapp'); +logger.setLevel(logging.log_level.DEBUG); -// create formatter and add it to the handlers -var formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'); -fh.setFormatter(formatter); -ch.setFormatter(formatter); +const handler = new ConsoleHandler(); +handler.level = logging.log_level.DEBUG; +handler.formatter = new Formatter({ + fmt: '%(asctime)s - %(name)s - %(levelname)s - %(message)s', +}); +logger.addHandler(handler); -// add the handlers to the logger -logger.addHandler(fh); -logger.addHandler(ch); - -logger.info('creating an instance of auxiliary_module.Auxiliary') +logger.info('Application starting'); +doWork(); +logger.info('Application finished'); ``` + +```javascript +// worker.js +import * as logging from '@administratrix/esm-logging'; + +const logger = logging.manager.MANAGER.getLogger('myapp.worker'); + +export function doWork() { + logger.debug('Starting work'); + // ... do something ... + logger.info('Work completed'); +} +``` + +Because `myapp.worker` is a child of `myapp`, its records propagate up to the +handler configured on `myapp`. No handler configuration is needed in +`worker.js`. + +Output: + +``` +2026-03-14 10:00:00.000 - myapp - INFO - Application starting +2026-03-14 10:00:00.001 - myapp.worker - DEBUG - Starting work +2026-03-14 10:00:00.002 - myapp.worker - INFO - Work completed +2026-03-14 10:00:00.003 - myapp - INFO - Application finished +``` + +## Logging to multiple destinations + +Attach multiple handlers to a single logger, each with its own level and +formatter: + +```javascript +import * as logging from '@administratrix/esm-logging'; +import { ConsoleHandler, StderrHandler } from '@administratrix/esm-logging/src/handler'; +import { Formatter } from '@administratrix/esm-logging/src/formatter'; + +const logger = logging.manager.MANAGER.getLogger('myapp'); +logger.setLevel(logging.log_level.DEBUG); + +// console handler: shows everything with detailed format +const consoleHandler = new ConsoleHandler(); +consoleHandler.level = logging.log_level.DEBUG; +consoleHandler.formatter = new Formatter({ + fmt: '%(asctime)s [%(levelname)s] %(name)s: %(message)s', +}); +logger.addHandler(consoleHandler); + +// stderr handler: only errors, compact format +const errorHandler = new StderrHandler(logging.log_level.ERROR); +errorHandler.formatter = new Formatter({ + fmt: 'ERROR %(asctime)s %(name)s: %(message)s', + datefmt: '%Y-%m-%d %H:%M:%S', +}); +logger.addHandler(errorHandler); +``` + +## Using basicConfig for simple scripts + +For quick scripts, `basicConfig` sets up a handler on the root logger: + +```javascript +import * as logging from '@administratrix/esm-logging'; + +logging.config.basicConfig({ + level: logging.log_level.INFO, + format: '%(levelname)s: %(message)s', +}); + +const logger = logging.manager.MANAGER.getLogger('script'); +logger.info('Running'); +logger.warning('Check this'); +``` + +Output: + +``` +INFO: Running +WARNING: Check this +``` + +## Filtering records + +### By scope + +Only allow records from a specific part of the hierarchy: + +```javascript +import { Filter } from '@administratrix/esm-logging/src/filter'; + +const dbFilter = new Filter('myapp.db'); +handler.addFilter(dbFilter); +// handler now only processes records from 'myapp.db' and its children +``` + +### By custom criteria + +Use any object with a `filter(record)` method: + +```javascript +handler.addFilter({ + filter(record) { + // only pass records that contain 'important' in the message + return record.msg.includes('important'); + } +}); +``` + +## Custom formatters + +Create formatters with different format strings for different contexts: + +```javascript +import { Formatter } from '@administratrix/esm-logging/src/formatter'; + +// detailed format for development +const devFormatter = new Formatter({ + fmt: '%(asctime)s %(levelname)s %(name)s: %(message)s', + datefmt: '%H:%M:%S', +}); + +// compact format for production +const prodFormatter = new Formatter({ + fmt: '%(levelname)s:%(name)s:%(message)s', +}); +``` + +## Custom handlers + +Subclass `Handler` and implement `emit(record)`: + +```javascript +import { Handler } from '@administratrix/esm-logging/src/handler'; + +class BufferHandler extends Handler { + constructor(level) { + super(level); + this.buffer = []; + } + + emit(record) { + try { + this.buffer.push(this.format(record)); + if (this.buffer.length >= 100) { + this.flush(); + } + } catch (e) { + this.handleError(record); + } + } + + flush() { + // send buffered records somewhere + const batch = this.buffer.splice(0); + // ... process batch ... + } +} +``` + +## Disabling logging below a threshold + +The manager's `disable` property suppresses all logging at or below a given +level across all loggers: + +```javascript +import * as logging from '@administratrix/esm-logging'; + +// suppress DEBUG and INFO globally +logging.manager.MANAGER.disable = logging.log_level.INFO; +``` + +## Reconfiguring with force + +`basicConfig` only takes effect once. To reconfigure, use `force: true`: + +```javascript +logging.config.basicConfig({ + level: logging.log_level.DEBUG, + format: '%(asctime)s %(message)s', + force: true, +}); +``` + +This removes and closes all existing handlers on the root logger before +applying the new configuration.