Compare commits
60 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34be2f8395 | ||
|
|
5b29c67d0d | ||
|
|
37fd6194c7 | ||
|
|
6fd8c22ceb | ||
|
|
7479931714 | ||
|
|
768ff9263d | ||
|
|
b254922d3c | ||
|
|
1a74f2afa4 | ||
|
|
741b959820 | ||
|
|
e50fccff8f | ||
|
|
743e8ffc3b | ||
|
|
5022a1e0bc | ||
|
|
94c7d4a1da | ||
|
|
cfda9902f7 | ||
|
|
1d5ded82ba | ||
|
|
672b692013 | ||
|
|
216d2b5892 | ||
|
|
3a6332a855 | ||
|
|
e6b7f02166 | ||
|
|
c297bb4499 | ||
|
|
1cab520ba1 | ||
|
|
67803747c4 | ||
|
|
3422cfb799 | ||
|
|
7b9e88ef59 | ||
|
|
c0ced4cda2 | ||
|
|
3dce9422ae | ||
|
|
3f424137ac | ||
|
|
2bdcb17ee6 | ||
|
|
fcfbd36153 | ||
|
|
d95c8d37da | ||
|
|
50df6b4c37 | ||
|
|
7ef841ac43 | ||
|
|
72b92af5aa | ||
|
|
6e82574723 | ||
|
|
3b6b116b00 | ||
|
|
0b87f5516a | ||
|
|
9fed1ffe6b | ||
|
|
e76d8fb77b | ||
|
|
340b31bc50 | ||
|
|
03e3641e03 | ||
|
|
df0126693e | ||
|
|
04e9768e89 | ||
|
|
25ec89c3b4 | ||
|
|
0cea450c0a | ||
|
|
b7c6937219 | ||
|
|
31f2d6e6f9 | ||
|
|
e6beeec594 | ||
|
|
b50586db8f | ||
|
|
bcb65bfec5 | ||
|
|
f069a0f2e4 | ||
|
|
ab4ef2baab | ||
|
|
9f4d9336d2 | ||
|
|
f45e75aa50 | ||
|
|
07b1228ac0 | ||
|
|
72248008b9 | ||
|
|
f38f62f53e | ||
|
|
fcf45f22a3 | ||
|
|
15e4a5f0e1 | ||
|
|
b159b44f7b | ||
|
|
be34f5c670 |
25 changed files with 3598 additions and 322 deletions
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "vendor/tiara-gitflow-spec"]
|
||||
path = vendor/tiara-gitflow-spec
|
||||
url = git@bitbucket.org:byteb4rb1e/tiara-gitflow-spec.git
|
||||
104
README.md
104
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
|
||||
|
|
|
|||
239
TODO
239
TODO
|
|
@ -1,51 +1,5 @@
|
|||
# TODO List for esm-logging
|
||||
|
||||
This is a poor-man's issue tracker. I am not primarily a GitHub user so don't
|
||||
want to commit to their issue tracking feature, but my primary SVC service
|
||||
provider (Bitbucket) only offers paid integration into their issue tracker
|
||||
(Jira). I don't have the time (and patience) at the moment to analyze the best
|
||||
approach, so this file will have to suffice.
|
||||
|
||||
It's a very simple concept: Track any issues (features, bugfixes, hotfixes) in
|
||||
here, assign a sequential number to it and use that number when branching.
|
||||
|
||||
I will try to develop a format so that I can parse the file later on, should I
|
||||
decide to migrate to a real issue tracker. It's probably going to be Bugzilla,
|
||||
but for that my html-theme-ref project needs to stabilize first.
|
||||
|
||||
## Format Specification
|
||||
|
||||
The file uses Markdown conventions for formatting headers and other text block
|
||||
entitities, but SHOULD NOT be considered a Markdown file. That's why it has no
|
||||
definitive file extension.
|
||||
|
||||
Each issue entry follows a structured format for easier parsing and future
|
||||
migration. Issues MUST be **appended** to this file and never moved, to
|
||||
preserve Git diffing.
|
||||
|
||||
### Issue Format
|
||||
|
||||
```
|
||||
|
||||
ID: [ISSUE-NUMBER]
|
||||
Type: [feature/bugfix/hotfix]
|
||||
Title: [Short title]
|
||||
Status: [open/in-progress/done]
|
||||
Priority: [low/medium/high]
|
||||
Created: [YYYY-MM-DD]
|
||||
Description: [Detailed explanation]
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
- ISSUE-NUMBERs must be sequential
|
||||
- truncation of description must be indentended so that every line starts at the
|
||||
same column
|
||||
- issues must be started with two LF
|
||||
- issues must be terminated with two LF, then `---`
|
||||
- issues may have a free-text field (epilog), which must be started with two LF.
|
||||
|
||||
## Issues
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 1
|
||||
Type: feature
|
||||
|
|
@ -53,12 +7,14 @@ Title: string formatting utilities
|
|||
Status: in-progress
|
||||
Priority: high
|
||||
Created: 2025-05-01
|
||||
Relationships:
|
||||
Description: implement utilities for formatting strings. The formatting should
|
||||
be inspired by Python 3K PEP 3101 in addition to their standard
|
||||
library utilities starting from ver. 3.7. Optimizations should
|
||||
focus on V8 support.
|
||||
|
||||
---
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 2
|
||||
Type: feature
|
||||
|
|
@ -66,10 +22,193 @@ Title: describe development workflow in CONTRIBUTING.md
|
|||
Status: open
|
||||
Priority: medium
|
||||
Created: 2025-05-01
|
||||
Relationships:
|
||||
Description: It's a good idea to describe the development workflow, including
|
||||
branching strategies earlier on, so that if someone is interested
|
||||
in forking, they can pick up right away. It's not meant for
|
||||
contributions though. I'm currently not interested in external
|
||||
contributions.
|
||||
|
||||
---
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 3
|
||||
Type: bugfix
|
||||
Title: modularize testing further
|
||||
Status: done
|
||||
Priority: high
|
||||
Created: 2025-05-01
|
||||
Relationships:
|
||||
Description: Since I am going to implement unit tests as well as integration
|
||||
tests and probably some benchmarks, it makes sense to introduce
|
||||
another sub-level directory for each type of test, say
|
||||
tests/unit/, tests/integration, etc.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 4
|
||||
Type: feature
|
||||
Title: migrate testing framework from Jest to Mocha
|
||||
Status: in-progress
|
||||
Priority: high
|
||||
Created: 2025-05-01
|
||||
Relationships:
|
||||
Description: I really don't like behavior-driven testing, at least when it comes
|
||||
to unit testing. It feels like Walldorf education... Where I need
|
||||
to come up with an abstraction for describing my test. A function
|
||||
has an input and gives an output. That's what I want to test. I'm
|
||||
not trying to find the philosophical meaning of my functions...
|
||||
Hopefully Mocha is the savior. I'm sticking to xUnit based testing
|
||||
from now on.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 5
|
||||
Type: bugfix
|
||||
Title: fix critical bugs across core modules
|
||||
Status: done
|
||||
Priority: high
|
||||
Created: 2026-03-13
|
||||
Relationships:
|
||||
Description: multiple logic bugs prevent the library from functioning.
|
||||
manager.ts getLogger() has an inverted type check and missing
|
||||
return statement. config.ts basicConfig() assigns wrong option
|
||||
fields (filemode instead of datefmt/style) and has unreachable
|
||||
conditions. logger.ts isEnabledFor() always caches false,
|
||||
_log() never calls handle(), and makeRecord() uses .keys()
|
||||
instead of Object.keys(). handler.ts format() has no return
|
||||
statement and is missing a formatter getter.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 6
|
||||
Type: feature
|
||||
Title: implement remaining Logger level methods
|
||||
Status: done
|
||||
Priority: high
|
||||
Created: 2026-03-13
|
||||
Relationships: dependsOn:5
|
||||
Description: Logger only implements debug(). The info(), warning(), error(),
|
||||
and critical() methods are missing entirely. The _log() method
|
||||
also needs to be completed so it actually calls handle() after
|
||||
creating the LogRecord.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 7
|
||||
Type: feature
|
||||
Title: implement Formatter subsystem
|
||||
Status: done
|
||||
Priority: high
|
||||
Created: 2026-03-13
|
||||
Relationships: dependsOn:1
|
||||
Description: the three core Formatter methods are stubs returning placeholder
|
||||
strings. PercentFormatterStyle._format() needs to perform actual
|
||||
%-style field substitution against LogRecord attributes.
|
||||
Formatter.formatTime() needs a JS Date/Intl-based implementation
|
||||
replacing the Python time.strftime references.
|
||||
Formatter.formatError() needs to format Error.stack into a
|
||||
string. The datetime helper module is empty and needs time
|
||||
formatting utilities.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 8
|
||||
Type: feature
|
||||
Title: implement browser-compatible Handler output
|
||||
Status: done
|
||||
Priority: high
|
||||
Created: 2026-03-13
|
||||
Relationships: dependsOn:5
|
||||
Description: StreamHandler has no emit() implementation and its constructor
|
||||
ignores the stream parameter. The helper/stream module is a
|
||||
stub that needs a browser-compatible Writable interface backed
|
||||
by console output. FileHandler should be dropped or gated behind
|
||||
a Node.js environment check since browser environments have no
|
||||
filesystem write access. A ConsoleHandler targeting the browser
|
||||
console API (console.log/warn/error by level) is the primary
|
||||
output target.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 9
|
||||
Type: feature
|
||||
Title: implement Manager logger hierarchy
|
||||
Status: done
|
||||
Priority: high
|
||||
Created: 2026-03-13
|
||||
Relationships: dependsOn:5
|
||||
Description: Manager.getLogger() does not establish parent-child
|
||||
relationships between loggers based on dot-separated names.
|
||||
The Placeholder fixup logic is incomplete. Logger propagation
|
||||
depends on this hierarchy being correctly built so that child
|
||||
loggers forward records to parent handlers.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 10
|
||||
Type: feature
|
||||
Title: add documentation
|
||||
Status: done
|
||||
Priority: medium
|
||||
Created: 2026-03-13
|
||||
Relationships:
|
||||
Description: write user-facing documentation covering module usage, API
|
||||
reference, and configuration. The docs/ directory has stubs
|
||||
(README.md, logging-cookbook.md) that need to be fleshed out.
|
||||
TypeDoc generates API docs from source but a prose guide with
|
||||
examples is needed for onboarding.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 11
|
||||
Type: feature
|
||||
Title: add support for asynchronous calls
|
||||
Status: open
|
||||
Priority: low
|
||||
Created: 2026-03-13
|
||||
Relationships:
|
||||
Description: introduce async-aware logging so that handlers which perform
|
||||
I/O (e.g. network, storage) do not block the caller. This may
|
||||
involve an AsyncHandler base class or async emit() overloads,
|
||||
and consideration of how log record ordering is preserved
|
||||
across concurrent async contexts.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 12
|
||||
Type: feature
|
||||
Title: implement OCSF formatter
|
||||
Status: open
|
||||
Priority: low
|
||||
Created: 2026-03-13
|
||||
Relationships:
|
||||
Description: implement a Formatter subclass that outputs log records in
|
||||
Open Cybersecurity Schema Framework (OCSF) format. This
|
||||
enables structured security event logging compatible with
|
||||
SIEM systems and security analytics tooling.
|
||||
|
||||
--ISSUE
|
||||
Content-Type: application/issue
|
||||
|
||||
ID: 13
|
||||
Type: feature
|
||||
Title: implement browser local storage handler
|
||||
Status: done
|
||||
Priority: medium
|
||||
Created: 2026-03-13
|
||||
Relationships: dependsOn:8
|
||||
Description: implement a Handler subclass that persists log records to
|
||||
browser localStorage or IndexedDB as a replacement for
|
||||
FileHandler in browser environments. Should support log
|
||||
rotation by size or entry count to avoid exceeding storage
|
||||
quotas.
|
||||
|
|
|
|||
334
docs/README.md
334
docs/README.md
|
|
@ -1 +1,333 @@
|
|||
The doc/README.md
|
||||
# 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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
7
eslint.config.mjs
Normal file
7
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
);
|
||||
|
|
@ -19,5 +19,5 @@ export default {
|
|||
}
|
||||
],
|
||||
],
|
||||
roots: ['./tests']
|
||||
roots: ['./tests/unit']
|
||||
}
|
||||
|
|
|
|||
1252
package-lock.json
generated
1252
package-lock.json
generated
File diff suppressed because it is too large
Load diff
20
package.json
20
package.json
|
|
@ -3,14 +3,15 @@
|
|||
"version": "1.0.0",
|
||||
"description": "port of Python standard library logging module",
|
||||
"main": "lib/index.js",
|
||||
"engines": {
|
||||
"node": ">= 20.11.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "npm run build:release",
|
||||
"build:release": "tsc",
|
||||
"build:debug": "tsc -p tsconfig.debug.json",
|
||||
"doc": "typedoc --entryPoints src/index.ts --html build/doc",
|
||||
"publish_": "ts-node -P tsconfig.node.json scripts/publish.ts",
|
||||
"dist": "ts-node -P tsconfig.node.json scripts/npm-pack.ts build/release dist"
|
||||
"build/debug": "tsc -p tsconfig.debug.json",
|
||||
"build/release": "tsc",
|
||||
"build/doc": "typedoc",
|
||||
"dist": "ts-node -P tsconfig.node.json scripts/npm-pack.ts build/release dist",
|
||||
"test-reports/unit": "jest"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -19,13 +20,16 @@
|
|||
"author": "Tiara Rodney",
|
||||
"license": "UNLICENSED",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"eslint": "^9.39.4",
|
||||
"jest": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"ts-jest": "^29.3.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.28.3",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.57.0"
|
||||
},
|
||||
"overrides": {
|
||||
"jest": {
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ export function basicConfig(options: BasicConfigOptions) {
|
|||
const filename = options.filename ?? null;
|
||||
const stream = options.stream ?? null;
|
||||
const filemode = options.filemode ?? 'a';
|
||||
const dateformat = options.filemode ?? null;
|
||||
const style = options.filemode ?? '%';
|
||||
const dateformat = options.datefmt;
|
||||
const style = options.style ?? '%';
|
||||
const level = options.level ?? null;
|
||||
|
||||
if (!Object.keys(STYLES).includes(style)) {
|
||||
|
|
@ -139,64 +139,54 @@ export function basicConfig(options: BasicConfigOptions) {
|
|||
}
|
||||
}
|
||||
|
||||
if (handlers.length == 0) {
|
||||
if (handlers === null && stream && filename) {
|
||||
if (MANAGER.root.handlers.length > 0 && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlers.length > 0) {
|
||||
if (stream || filename) {
|
||||
throw new ValueError(
|
||||
"'stream' or 'filename' should not be specified together " +
|
||||
"with 'handlers'"
|
||||
);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (stream && filename) {
|
||||
throw new ValueError(
|
||||
"'stream' and 'filename' should not be specified together"
|
||||
);
|
||||
}
|
||||
|
||||
else if (stream || filename) {
|
||||
throw new ValueError(
|
||||
"'stream' or 'filename' should not be specified together" +
|
||||
"with 'handlers'"
|
||||
);
|
||||
if (filename) {
|
||||
if (filemode.match('b')) { errors = undefined }
|
||||
else { encoding = 'utf-8' }
|
||||
|
||||
handlers = [new FileHandler({
|
||||
filename: filename,
|
||||
filemode: filemode,
|
||||
encoding: encoding,
|
||||
errors: errors
|
||||
})];
|
||||
}
|
||||
|
||||
if (handlers === null) {
|
||||
var h: Handler;
|
||||
|
||||
if (filename) {
|
||||
if (filemode.match('b')) { errors = undefined }
|
||||
else { encoding = 'utf-8' }
|
||||
|
||||
h = new FileHandler({
|
||||
filename: filename,
|
||||
filemode: filemode,
|
||||
'encoding': encoding,
|
||||
errors: errors
|
||||
});
|
||||
}
|
||||
|
||||
else { h = new StreamHandler(stream) }
|
||||
|
||||
handlers = [h];
|
||||
}
|
||||
|
||||
for (var i = 0; i < handlers.length; i += 1) {
|
||||
let h = handlers[i];
|
||||
|
||||
if (h.formatter === null) {
|
||||
h.formatter = new Formatter({
|
||||
fmt: options.format ?? STYLES[style][1],
|
||||
datefmt: dateformat,
|
||||
style: style
|
||||
});
|
||||
}
|
||||
|
||||
MANAGER.root.addHandler(h);
|
||||
}
|
||||
|
||||
if (level !== null) { MANAGER.root.setLevel(level) }
|
||||
|
||||
if (options) {
|
||||
// runtime interface guard, please let me stay. 🥺
|
||||
// the interface does not allow for additional members, but the
|
||||
// runtime environment has no concept of interfaces. We can stick to
|
||||
// the original implementation
|
||||
const keys = Object.keys(options).join(', ');
|
||||
|
||||
throw new ValueError(`Unrecognised argument(s): ${keys}`);
|
||||
else {
|
||||
handlers = [new StreamHandler(stream)];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < handlers.length; i += 1) {
|
||||
const h = handlers[i];
|
||||
|
||||
if (h.formatter === null) {
|
||||
h.formatter = new Formatter({
|
||||
fmt: options.format ?? STYLES[style][1],
|
||||
datefmt: dateformat,
|
||||
style: style
|
||||
});
|
||||
}
|
||||
|
||||
MANAGER.root.addHandler(h);
|
||||
}
|
||||
|
||||
if (level !== null) { MANAGER.root.setLevel(level) }
|
||||
}
|
||||
|
|
|
|||
155
src/formatter.ts
155
src/formatter.ts
|
|
@ -10,6 +10,12 @@ export interface PercentFormatterStyleOptions {
|
|||
defaults: {[key: string]: any};
|
||||
}
|
||||
|
||||
/**
|
||||
* %-style formatting for log records.
|
||||
*
|
||||
* Substitutes %(name)s, %(name)d, %(name)f placeholders from record
|
||||
* attributes.
|
||||
*/
|
||||
class PercentFormatterStyle {
|
||||
public static defaultFormat = '%(message)s';
|
||||
public static asctimeFormat = '%(asctime)s';
|
||||
|
|
@ -17,7 +23,7 @@ class PercentFormatterStyle {
|
|||
public static validationPattern =
|
||||
/%\(\w+\)[#0+ -]*(\*|\d+)?(\.(\*|\d+))?[diouxefgcrsa%]/;
|
||||
|
||||
private fmt: string;
|
||||
public fmt: string;
|
||||
private defaults: {[key: string]: any};
|
||||
|
||||
constructor(options: PercentFormatterStyleOptions) {
|
||||
|
|
@ -26,7 +32,7 @@ class PercentFormatterStyle {
|
|||
}
|
||||
|
||||
usesTime(): boolean {
|
||||
return this.fmt.match(PercentFormatterStyle.asctimeFormat) ? true : false
|
||||
return this.fmt.indexOf(PercentFormatterStyle.asctimeSearch) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,12 +48,32 @@ class PercentFormatterStyle {
|
|||
}
|
||||
|
||||
protected _format(record: LogRecord): string {
|
||||
var defaults = this.defaults;
|
||||
var values: {[key: string]: any}|null;
|
||||
if (defaults) { values = {...this.defaults, ...Object.entries(record)} }
|
||||
else { values = Object.entries(record) }
|
||||
//TODO: implement formatting
|
||||
return 'would do some formatting';
|
||||
const values: {[key: string]: any} = {
|
||||
...this.defaults,
|
||||
...(record as unknown as {[key: string]: any}),
|
||||
};
|
||||
|
||||
return this.fmt.replace(
|
||||
/%\((\w+)\)([#0+ -]*(?:\*|\d+)?(?:\.(?:\*|\d+))?[diouxefgcrsa%])/g,
|
||||
(_match, key, spec) => {
|
||||
if (!(key in values)) {
|
||||
throw new ValueError(`formatting field not found in record: ${key}`);
|
||||
}
|
||||
const val = values[key];
|
||||
const conversion = spec[spec.length - 1];
|
||||
switch (conversion) {
|
||||
case 'd':
|
||||
case 'i':
|
||||
return String(Math.floor(Number(val)));
|
||||
case 'f':
|
||||
return String(Number(val));
|
||||
case 's':
|
||||
return String(val);
|
||||
default:
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
format(record: LogRecord): string {
|
||||
|
|
@ -55,12 +81,13 @@ class PercentFormatterStyle {
|
|||
return this._format(record)
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof ValueError) { throw e }
|
||||
throw new ValueError(`formatting field not found in record: ${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BASIC_FORMAT = '%(level)s:%(name)s:%(message)s';
|
||||
const BASIC_FORMAT = '%(levelname)s:%(name)s:%(message)s';
|
||||
|
||||
export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOptions): PercentFormatterStyle}, string]} = {
|
||||
'%': [PercentFormatterStyle, BASIC_FORMAT],
|
||||
|
|
@ -68,7 +95,7 @@ export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOption
|
|||
|
||||
export interface FormatterOptions {
|
||||
fmt?: string
|
||||
datefmt?: any
|
||||
datefmt?: string
|
||||
style?: string
|
||||
validate?: boolean
|
||||
defaults?: {[key: string]: any}
|
||||
|
|
@ -95,53 +122,26 @@ export interface FormatterOptions {
|
|||
* WARNING, ERROR, CRITICAL)
|
||||
* %(levelname)s Text logging level for the message ("DEBUG", "INFO",
|
||||
* "WARNING", "ERROR", "CRITICAL")
|
||||
* %(pathname)s Full pathname of the source file where the logging
|
||||
* call was issued (if available)
|
||||
* %(filename)s Filename portion of pathname
|
||||
* %(module)s Module (name portion of filename)
|
||||
* %(lineno)d Source line number where the logging call was issued
|
||||
* (if available)
|
||||
* %(funcName)s Function name
|
||||
* %(created)f Time when the LogRecord was created (time.time_ns() / 1e9
|
||||
* %(created)f Time when the LogRecord was created (Date.now()
|
||||
* return value)
|
||||
* %(asctime)s Textual time when the LogRecord was created
|
||||
* %(msecs)d Millisecond portion of the creation time
|
||||
* %(relativeCreated)d Time in milliseconds when the LogRecord was created,
|
||||
* relative to the time the logging module was loaded
|
||||
* (typically at application startup time)
|
||||
* %(thread)d Thread ID (if available)
|
||||
* %(threadName)s Thread name (if available)
|
||||
* %(taskName)s Task name (if available)
|
||||
* %(process)d Process ID (if available)
|
||||
* %(message)s The result of record.getMessage(), computed just as
|
||||
* the record is emitted
|
||||
*/
|
||||
export class Formatter {
|
||||
public static defaultTimeFormat = '%Y-%M';
|
||||
public static defaultMsecFormat = '%s,%30d';
|
||||
public static defaultTimeFormat = 'YYYY-MM-DDTHH:mm:ss';
|
||||
public static defaultMsecFormat = '%s.%03d';
|
||||
|
||||
protected style: any;
|
||||
protected style: PercentFormatterStyle;
|
||||
protected fmt: string;
|
||||
protected datefmt: any;
|
||||
protected datefmt: string|undefined;
|
||||
|
||||
/**
|
||||
* Initialize the formatter with specified format strings.
|
||||
*
|
||||
* Initialize the formatter either with the specified format string, or a
|
||||
* default as described above. Allow for specialized date formatting with
|
||||
* the optional datefmt argument. If datefmt is omitted, you get an
|
||||
* ISO8601-like (or RFC 3339-like) format.
|
||||
*
|
||||
* Use a style parameter of '%', '{' or '$' to specify that you want to
|
||||
* use one of %-formatting, :meth:`str.format` (``{}``) formatting or
|
||||
* :class:`string.Template` formatting in your format string.
|
||||
*/
|
||||
constructor(options?: FormatterOptions) {
|
||||
options = options ?? {};
|
||||
var style = options.style ?? '%';
|
||||
var validate = options.validate ?? true;
|
||||
const style = options.style ?? '%';
|
||||
const validate = options.validate ?? true;
|
||||
|
||||
if (!Object.keys(STYLES).includes(style ?? '')) {
|
||||
if (!Object.keys(STYLES).includes(style)) {
|
||||
throw new ValueError(`style must be one of: ${Object.keys(STYLES).join(', ')}`)
|
||||
}
|
||||
|
||||
|
|
@ -157,45 +157,60 @@ export class Formatter {
|
|||
this.datefmt = options.datefmt;
|
||||
}
|
||||
|
||||
usesTime(): boolean {
|
||||
return this.style.usesTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the specified record as text.
|
||||
*
|
||||
* The record's attribute dictionary is used as the operand to a string
|
||||
* formatting operation which yields the returned string. Before the
|
||||
* formatting operation, a couple of preparatory steps are carried out.
|
||||
* The message attribute of the record is computed using LogRecord.getMessage().
|
||||
* If the formatting string uses the time, formatTime() is called to format
|
||||
* the event time.
|
||||
*/
|
||||
format(record: LogRecord): string {
|
||||
record.message = record.getMessage();
|
||||
|
||||
if (this.usesTime()) {
|
||||
record.asctime = this.formatTime(record, this.datefmt);
|
||||
}
|
||||
|
||||
return this.style.format(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the creation time of the specified LogRecord as formatted text.
|
||||
*
|
||||
* This method should be called from format() by a formatter which
|
||||
* wants to make use of a formatted time. This method can be overridden
|
||||
* in formatters to provide for any specific requirement, but the
|
||||
* basic behaviour is as follows: if datefmt (a string) is specified,
|
||||
* it is used with time.strftime() to format the creation time of the
|
||||
* record. Otherwise, an ISO8601-like (or RFC 3339-like) format is used.
|
||||
* The resulting string is returned. This function uses a user-configurable
|
||||
* function to convert the creation time to a tuple. By default,
|
||||
* time.localtime() is used; to change this for a particular formatter
|
||||
* instance, set the 'converter' attribute to a function with the same
|
||||
* signature as time.localtime() or time.gmtime(). To change it for all
|
||||
* formatters, for example if you want all logging times to be shown in GMT,
|
||||
* set the 'converter' attribute in the Formatter class.
|
||||
* If datefmt is specified, it is used as a strftime-style format string.
|
||||
* Supported tokens: %Y, %m, %d, %H, %M, %S.
|
||||
* Otherwise, an ISO8601-like format is used.
|
||||
*/
|
||||
formatTime(record: LogRecord, datefmt?: any): string {
|
||||
formatTime(record: LogRecord, datefmt?: string): string {
|
||||
const dt = new Date(record.created);
|
||||
|
||||
//TODO: record.created
|
||||
if (datefmt) {
|
||||
//TODO: time.strftime
|
||||
}
|
||||
else {
|
||||
//TODO: time.strftime
|
||||
return datefmt
|
||||
.replace('%Y', String(dt.getFullYear()))
|
||||
.replace('%m', String(dt.getMonth() + 1).padStart(2, '0'))
|
||||
.replace('%d', String(dt.getDate()).padStart(2, '0'))
|
||||
.replace('%H', String(dt.getHours()).padStart(2, '0'))
|
||||
.replace('%M', String(dt.getMinutes()).padStart(2, '0'))
|
||||
.replace('%S', String(dt.getSeconds()).padStart(2, '0'));
|
||||
}
|
||||
|
||||
return 'some time';
|
||||
const iso = dt.toISOString();
|
||||
return iso.replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format and return the specified exception information as a string.
|
||||
|
||||
* This default implementation just uses
|
||||
* traceback.print_exception()
|
||||
*/
|
||||
formatError(ei: MyError): string {
|
||||
//TODO
|
||||
return 'some error';
|
||||
if (ei.stack) { return ei.stack }
|
||||
return String(ei);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
240
src/handler.ts
240
src/handler.ts
|
|
@ -1,17 +1,9 @@
|
|||
import * as stream from 'stream';
|
||||
|
||||
import { LogLevel, checkLevel, NOTSET } from './log-level';
|
||||
import { LogLevel, checkLevel, NOTSET, WARNING, ERROR } from './log-level';
|
||||
import { LogRecord } from './log-record';
|
||||
import { Formatter, DEFAULT_FORMATTER } from './formatter';
|
||||
import { Filterer } from './filter';
|
||||
import { NotImplementedError } from './helper/error';
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const stream = require('stream');
|
||||
}
|
||||
else {
|
||||
const stream = require('./helper/stream');
|
||||
}
|
||||
import { Writable, DEFAULT_STREAM, StderrWritable } from './helper/stream';
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
// Handler classes and functions
|
||||
|
|
@ -78,7 +70,7 @@ export class Handler extends Filterer {
|
|||
}
|
||||
|
||||
get level(): number { return this._level }
|
||||
set level(level: LogLevel|string) { this.level = checkLevel(level) }
|
||||
set level(level: LogLevel|string) { this._level = checkLevel(level) }
|
||||
|
||||
get scope(): string|null { return this._scope }
|
||||
set scope(scope: string) { this._scope = scope }
|
||||
|
|
@ -90,11 +82,9 @@ export class Handler extends Filterer {
|
|||
* If a formatter is set, use it. Otherwise, use the default formatter for
|
||||
* the module.
|
||||
*/
|
||||
format(record: LogRecord) {
|
||||
var fmt: Formatter|null = null;
|
||||
|
||||
if (this.formatter) { fmt = this.formatter }
|
||||
else { fmt = DEFAULT_FORMATTER }
|
||||
format(record: LogRecord): string {
|
||||
const fmt = this.formatter ?? DEFAULT_FORMATTER;
|
||||
return fmt.format(record);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -117,14 +107,13 @@ export class Handler extends Filterer {
|
|||
* I/O thread lock.
|
||||
*/
|
||||
handle(record: LogRecord) {
|
||||
var rv = this.filter(record);
|
||||
const rv = this.filter(record);
|
||||
if (!rv) { return }
|
||||
let filtered = record;
|
||||
if ((rv as any) instanceof LogRecord) {
|
||||
record = rv as unknown as LogRecord
|
||||
}
|
||||
if (rv) {
|
||||
//locking here
|
||||
this.emit(record)
|
||||
filtered = rv as unknown as LogRecord
|
||||
}
|
||||
this.emit(filtered)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -146,22 +135,73 @@ export class Handler extends Filterer {
|
|||
* Handle errors which occur during an emit() call.
|
||||
*
|
||||
* This method should be called from handlers when an exception is
|
||||
* encountered during an emit() call. If raiseExceptions is false,
|
||||
* exceptions get silently ignored. This is what is mostly wanted
|
||||
* for a logging system - most users will not care about errors in
|
||||
* the logging system, they are more interested in application errors.
|
||||
* You could, however, replace this with a custom handler if you wish.
|
||||
* The record which was being processed is passed in to this method.
|
||||
* encountered during an emit() call.
|
||||
*/
|
||||
handleError(record: LogRecord) {
|
||||
throw new NotImplementedError(
|
||||
'still need to find portable way for stacktracing...'
|
||||
)
|
||||
try {
|
||||
console.error('--- Logging error ---');
|
||||
console.error('Error in handler for record:', record.scope, record.msg);
|
||||
}
|
||||
catch (_) {
|
||||
// silently ignore errors in error handling
|
||||
}
|
||||
}
|
||||
|
||||
get formatter(): Formatter|null { return this._formatter }
|
||||
set formatter(fmt: Formatter) { this._formatter = fmt }
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler class which writes logging records, appropriately formatted,
|
||||
* to a stream. Note that this class does not close the stream, as
|
||||
* the default stream may be shared.
|
||||
*/
|
||||
export class StreamHandler extends Handler {
|
||||
protected stream: Writable;
|
||||
|
||||
constructor(stream?: Writable) {
|
||||
super();
|
||||
this.stream = stream ?? DEFAULT_STREAM;
|
||||
}
|
||||
|
||||
emit(record: LogRecord) {
|
||||
try {
|
||||
const msg = this.format(record);
|
||||
this.stream.write(msg);
|
||||
}
|
||||
catch (e) {
|
||||
this.handleError(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler class which writes logging records to the browser console,
|
||||
* mapping log levels to the appropriate console method.
|
||||
*
|
||||
* This is the primary handler for browser environments.
|
||||
*/
|
||||
export class ConsoleHandler extends Handler {
|
||||
emit(record: LogRecord) {
|
||||
try {
|
||||
const msg = this.format(record);
|
||||
|
||||
if (record.levelno >= ERROR) {
|
||||
console.error(msg);
|
||||
}
|
||||
else if (record.levelno >= WARNING) {
|
||||
console.warn(msg);
|
||||
}
|
||||
else {
|
||||
console.log(msg);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
this.handleError(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileHandlerOptions {
|
||||
filename: string
|
||||
filemode?: string
|
||||
|
|
@ -169,20 +209,121 @@ export interface FileHandlerOptions {
|
|||
errors?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler class which writes logging records, appropriately formatted,
|
||||
to a stream. Note that this class does not close the stream, as
|
||||
sys.stdout or sys.stderr may be used.
|
||||
*/
|
||||
export class StreamHandler extends Handler {
|
||||
constructor(stream?: stream.Writable) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class FileHandler extends StreamHandler {
|
||||
constructor(options: FileHandlerOptions) {
|
||||
super();
|
||||
throw new NotImplementedError(
|
||||
'FileHandler is not available in browser environments. ' +
|
||||
'Use ConsoleHandler or a storage-backed handler instead.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler that persists log records to browser localStorage.
|
||||
*
|
||||
* Records are stored as a JSON array of formatted strings under a
|
||||
* configurable key. Log rotation is supported by entry count and/or
|
||||
* byte size to avoid exceeding storage quotas.
|
||||
*/
|
||||
export interface LocalStorageHandlerOptions {
|
||||
/**
|
||||
* localStorage key to store entries under.
|
||||
* Defaults to 'esm-logging'.
|
||||
*/
|
||||
key?: string;
|
||||
|
||||
/**
|
||||
* Maximum number of entries to retain. Oldest entries are discarded
|
||||
* first. Set to 0 for no entry limit. Defaults to 1000.
|
||||
*/
|
||||
maxEntries?: number;
|
||||
|
||||
/**
|
||||
* Maximum size in bytes (as measured by the JSON-serialized string
|
||||
* length). Oldest entries are discarded to stay within this limit.
|
||||
* Set to 0 for no byte limit. Defaults to 0.
|
||||
*/
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export class LocalStorageHandler extends Handler {
|
||||
protected key: string;
|
||||
protected maxEntries: number;
|
||||
protected maxBytes: number;
|
||||
|
||||
constructor(options?: LocalStorageHandlerOptions) {
|
||||
super();
|
||||
|
||||
if (typeof localStorage === 'undefined') {
|
||||
throw new NotImplementedError(
|
||||
'LocalStorageHandler requires a browser environment with localStorage'
|
||||
);
|
||||
}
|
||||
|
||||
this.key = options?.key ?? 'esm-logging';
|
||||
this.maxEntries = options?.maxEntries ?? 1000;
|
||||
this.maxBytes = options?.maxBytes ?? 0;
|
||||
}
|
||||
|
||||
emit(record: LogRecord) {
|
||||
try {
|
||||
const msg = this.format(record);
|
||||
const entries = this.getEntries();
|
||||
entries.push(msg);
|
||||
this._rotate(entries);
|
||||
this._setEntries(entries);
|
||||
}
|
||||
catch (e) {
|
||||
this.handleError(record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all stored log entries.
|
||||
*/
|
||||
getEntries(): string[] {
|
||||
try {
|
||||
const data = localStorage.getItem(this.key);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all stored log entries.
|
||||
*/
|
||||
clearEntries() {
|
||||
localStorage.removeItem(this.key);
|
||||
}
|
||||
|
||||
close() {
|
||||
super.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard oldest entries to stay within maxEntries and maxBytes limits.
|
||||
*/
|
||||
protected _rotate(entries: string[]) {
|
||||
if (this.maxEntries > 0) {
|
||||
while (entries.length > this.maxEntries) {
|
||||
entries.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.maxBytes > 0) {
|
||||
while (entries.length > 0) {
|
||||
const serialized = JSON.stringify(entries);
|
||||
if (serialized.length <= this.maxBytes) { break }
|
||||
entries.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _setEntries(entries: string[]) {
|
||||
localStorage.setItem(this.key, JSON.stringify(entries));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,9 +332,16 @@ export class FileHandler extends StreamHandler {
|
|||
* whatever sys.stderr is currently set to rather than the value of
|
||||
* sys.stderr at handler construction time.
|
||||
*/
|
||||
export class StderrHandler extends Handler {
|
||||
/**
|
||||
* Initialize the handler.
|
||||
*/
|
||||
export class StderrHandler extends Handler {
|
||||
constructor(level: LogLevel) { super(level) }
|
||||
|
||||
emit(record: LogRecord) {
|
||||
try {
|
||||
const msg = this.format(record);
|
||||
console.error(msg);
|
||||
}
|
||||
catch (e) {
|
||||
this.handleError(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,33 @@
|
|||
/**
|
||||
* Minimal writable stream interface for browser compatibility.
|
||||
*
|
||||
* This abstracts over Node.js streams and browser console output,
|
||||
* providing a common write target for handlers.
|
||||
*/
|
||||
export interface Writable {
|
||||
write(data: string): void;
|
||||
}
|
||||
|
||||
export type MillisecondsSinceUnixEpoch = number;
|
||||
/**
|
||||
* A Writable backed by console.log.
|
||||
*/
|
||||
export class ConsoleWritable implements Writable {
|
||||
write(data: string): void {
|
||||
console.log(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Writable backed by console.error (stderr equivalent).
|
||||
*/
|
||||
export class StderrWritable implements Writable {
|
||||
write(data: string): void {
|
||||
console.error(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default output stream. Uses console.error to match Python's default
|
||||
* of writing to sys.stderr.
|
||||
*/
|
||||
export const DEFAULT_STREAM: Writable = new StderrWritable();
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export * as formatter from './formatter';
|
|||
export * as handler from './handler';
|
||||
// screw community conventions, whoever came up with the idea of aliasing
|
||||
// imports in pascal case, or camel case doesn't seem to care about naming
|
||||
// collisions. I'm sticking to snake case as this avoids naming collisions.
|
||||
// collisions. I'm sticking to kebab case as this avoids naming collisions.
|
||||
export * as log_level from './log-level';
|
||||
export * as log_record from './log-record';
|
||||
export * as logger from './logger';
|
||||
|
|
|
|||
|
|
@ -48,13 +48,32 @@ export class LogRecord {
|
|||
public readonly levelno: LogLevel;
|
||||
public readonly levelname: string|LogLevel;
|
||||
public readonly scope: string;
|
||||
public readonly name: string;
|
||||
public readonly msg: string;
|
||||
public readonly args: any[]|undefined;
|
||||
public message: string = '';
|
||||
|
||||
public readonly created: MillisecondsSinceUnixEpoch = Date.now();
|
||||
public asctime: string = '';
|
||||
|
||||
constructor(scope: string, options: LogRecordOptions) {
|
||||
this.levelno = options.level;
|
||||
this.levelname = getLevelName(options.level);
|
||||
this.scope = scope;
|
||||
this.name = scope;
|
||||
this.msg = options.msg;
|
||||
this.args = options.args;
|
||||
}
|
||||
|
||||
getMessage(): string {
|
||||
let msg = String(this.msg);
|
||||
if (this.args && this.args.length > 0) {
|
||||
msg = this.args.reduce(
|
||||
(s, arg) => s.replace('%s', String(arg)),
|
||||
msg
|
||||
);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import {
|
||||
LogLevel,
|
||||
DEBUG,
|
||||
NOTSET,
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR,
|
||||
CRITICAL,
|
||||
NOTSET,
|
||||
checkLevel,
|
||||
} from './log-level';
|
||||
import {
|
||||
|
|
@ -108,10 +111,13 @@ export class Logger extends Filterer {
|
|||
|
||||
public set level(level: LogLevel) { this._level = checkLevel(level) }
|
||||
|
||||
public get manager(): Manager|null { return this._manager }
|
||||
|
||||
public set manager(manager: Manager) {
|
||||
if (this.manager) {
|
||||
if (this._manager) {
|
||||
throw new ValueError('logger can only be assigned to manager once');
|
||||
}
|
||||
this._manager = manager;
|
||||
}
|
||||
|
||||
public setLevel(level: LogLevel) {
|
||||
|
|
@ -143,13 +149,17 @@ export class Logger extends Filterer {
|
|||
public isEnabledFor(level: LogLevel): boolean {
|
||||
if (this.disabled) { return false }
|
||||
|
||||
if (this.cache[level] === undefined && this.manager && this.manager.disable < level) {
|
||||
return this.cache[level] = (
|
||||
level >= this.getEffectiveLevel()
|
||||
);
|
||||
if (level in this.cache) {
|
||||
return this.cache[level];
|
||||
}
|
||||
|
||||
return this.cache[level] = false;
|
||||
if (this._manager && this._manager.disable >= level) {
|
||||
this.cache[level] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.cache[level] = level >= this.getEffectiveLevel();
|
||||
return this.cache[level];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -166,6 +176,34 @@ export class Logger extends Filterer {
|
|||
if (this.isEnabledFor(DEBUG)) { this._log(DEBUG, msg, options) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Log 'msg % args' with severity 'INFO'
|
||||
*/
|
||||
public info(msg: string, options?: LogOptions) {
|
||||
if (this.isEnabledFor(INFO)) { this._log(INFO, msg, options) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Log 'msg % args' with severity 'WARNING'
|
||||
*/
|
||||
public warning(msg: string, options?: LogOptions) {
|
||||
if (this.isEnabledFor(WARNING)) { this._log(WARNING, msg, options) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Log 'msg % args' with severity 'ERROR'
|
||||
*/
|
||||
public error(msg: string, options?: LogOptions) {
|
||||
if (this.isEnabledFor(ERROR)) { this._log(ERROR, msg, options) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Log 'msg % args' with severity 'CRITICAL'
|
||||
*/
|
||||
public critical(msg: string, options?: LogOptions) {
|
||||
if (this.isEnabledFor(CRITICAL)) { this._log(CRITICAL, msg, options) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A factory method which can be overriden in subclasses to create
|
||||
* specialized LogRecords.
|
||||
|
|
@ -191,9 +229,9 @@ export class Logger extends Filterer {
|
|||
|
||||
var [k, v] = item;
|
||||
|
||||
if (['message', 'asctime'].includes(k as string) ||
|
||||
(rv as {[key: string]: any}).keys().includes(k as string)) {
|
||||
throw new KeyError('attempt to overwrite ${k} in LogRecord')
|
||||
if (['message', 'asctime'].includes(k) ||
|
||||
Object.keys(rv).includes(k)) {
|
||||
throw new KeyError(`attempt to overwrite ${k} in LogRecord`)
|
||||
}
|
||||
|
||||
(rv as any)[k] = options.extra![k as string] as any
|
||||
|
|
@ -226,7 +264,8 @@ export class Logger extends Filterer {
|
|||
}
|
||||
}
|
||||
|
||||
var record = this.makeRecord(this.scope, level, msg, options)
|
||||
const record = this.makeRecord(this.scope, level, msg, options);
|
||||
this.handle(this.scope, record);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ var loggerClass = Logger;
|
|||
* Placeholder instance
|
||||
*/
|
||||
class Placeholder {
|
||||
protected loggers: Logger[] = [];
|
||||
public loggers: Logger[] = [];
|
||||
|
||||
/**
|
||||
* initialize with the specified logger being a child of this placeholder.
|
||||
|
|
@ -71,20 +71,76 @@ export class Manager {
|
|||
* up the parent/child references which pointed to the placeholder to now
|
||||
* point to the logger.
|
||||
*/
|
||||
getLogger(scope: string) {
|
||||
var rv: null|Logger = null;
|
||||
getLogger(scope: string): Logger {
|
||||
let rv: Logger;
|
||||
|
||||
if (typeof scope != 'string') {
|
||||
if (scope in this.loggers) {
|
||||
const existing = this.loggers[scope];
|
||||
|
||||
rv = this.loggers[scope];
|
||||
|
||||
if (rv instanceof Placeholder) {
|
||||
var ph = rv;
|
||||
if (existing instanceof Placeholder) {
|
||||
rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET);
|
||||
rv.manager = this;
|
||||
this.loggers[scope] = rv;
|
||||
this._fixupChildren(existing, rv);
|
||||
this._fixupParents(rv);
|
||||
}
|
||||
else {
|
||||
rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET);
|
||||
this.loggers[scope] = rv;
|
||||
rv = existing;
|
||||
}
|
||||
}
|
||||
else {
|
||||
rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET);
|
||||
rv.manager = this;
|
||||
this.loggers[scope] = rv;
|
||||
this._fixupParents(rv);
|
||||
}
|
||||
|
||||
return rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that there are either loggers or placeholders all the way from
|
||||
* the specified logger to the root of the logger hierarchy.
|
||||
*/
|
||||
protected _fixupParents(logger: Logger) {
|
||||
const name = logger.scope;
|
||||
let i = name.lastIndexOf('.');
|
||||
let rv: Logger | null = null;
|
||||
|
||||
while (i > 0 && !rv) {
|
||||
const substr = name.substring(0, i);
|
||||
|
||||
if (!(substr in this.loggers)) {
|
||||
this.loggers[substr] = new Placeholder(logger) as unknown as Logger;
|
||||
}
|
||||
else {
|
||||
const obj = this.loggers[substr];
|
||||
|
||||
if (obj instanceof Placeholder) {
|
||||
obj.push(logger);
|
||||
}
|
||||
else {
|
||||
rv = obj;
|
||||
}
|
||||
}
|
||||
|
||||
i = name.lastIndexOf('.', i - 1);
|
||||
}
|
||||
|
||||
(logger as unknown as { parent: Logger }).parent = rv ?? this.root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that children of the placeholder ph are connected to the
|
||||
* specified logger.
|
||||
*/
|
||||
protected _fixupChildren(ph: Placeholder, logger: Logger) {
|
||||
const name = logger.scope;
|
||||
|
||||
for (const c of ph.loggers) {
|
||||
if (c.parent!.scope.substring(0, name.length) !== name) {
|
||||
(logger as unknown as { parent: Logger }).parent = c.parent!;
|
||||
(c as unknown as { parent: Logger }).parent = logger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,7 +171,9 @@ export class Manager {
|
|||
*/
|
||||
public clear() {
|
||||
Object.values(this.loggers).forEach((logger) => {
|
||||
logger.clear()
|
||||
if (!(logger instanceof Placeholder)) {
|
||||
logger.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
93
tests/unit/config.test.ts
Normal file
93
tests/unit/config.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import {expect, jest, test} from '@jest/globals';
|
||||
import { basicConfig } from '../../src/config';
|
||||
import { MANAGER } from '../../src/manager';
|
||||
import { Handler } from '../../src/handler';
|
||||
import { LogRecord } from '../../src/log-record';
|
||||
import { Formatter } from '../../src/formatter';
|
||||
import { DEBUG, WARNING, ERROR } from '../../src/log-level';
|
||||
describe('basicConfig', () => {
|
||||
beforeEach(() => {
|
||||
MANAGER.root.handlers.splice(0, MANAGER.root.handlers.length);
|
||||
});
|
||||
|
||||
test('adds a handler to the root logger', () => {
|
||||
basicConfig({});
|
||||
expect(MANAGER.root.handlers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('sets root logger level', () => {
|
||||
basicConfig({ level: DEBUG });
|
||||
expect(MANAGER.root.level).toBe(DEBUG);
|
||||
});
|
||||
|
||||
test('does nothing when handlers already exist and force is false', () => {
|
||||
basicConfig({ level: DEBUG });
|
||||
const handlerCount = MANAGER.root.handlers.length;
|
||||
basicConfig({ level: ERROR });
|
||||
expect(MANAGER.root.handlers.length).toBe(handlerCount);
|
||||
expect(MANAGER.root.level).toBe(DEBUG);
|
||||
});
|
||||
|
||||
test('replaces handlers when force is true', () => {
|
||||
basicConfig({ level: DEBUG });
|
||||
basicConfig({ level: ERROR, force: true });
|
||||
expect(MANAGER.root.level).toBe(ERROR);
|
||||
});
|
||||
|
||||
test('throws on invalid style', () => {
|
||||
expect(() => {
|
||||
basicConfig({ style: 'X' });
|
||||
}).toThrow('style must be one of');
|
||||
});
|
||||
|
||||
test('throws when stream and filename both specified', () => {
|
||||
expect(() => {
|
||||
basicConfig({
|
||||
stream: process.stderr,
|
||||
filename: 'test.log',
|
||||
});
|
||||
}).toThrow('should not be specified together');
|
||||
});
|
||||
|
||||
test('throws when handlers and stream both specified', () => {
|
||||
class TestHandler extends Handler {
|
||||
emit(record: LogRecord) {}
|
||||
}
|
||||
expect(() => {
|
||||
basicConfig({
|
||||
handlers: [new TestHandler()],
|
||||
stream: process.stderr,
|
||||
});
|
||||
}).toThrow('should not be specified together');
|
||||
});
|
||||
|
||||
test('uses provided handlers', () => {
|
||||
class TestHandler extends Handler {
|
||||
emit(record: LogRecord) {}
|
||||
}
|
||||
const handler = new TestHandler();
|
||||
basicConfig({ handlers: [handler] });
|
||||
expect(MANAGER.root.handlers).toContain(handler);
|
||||
});
|
||||
|
||||
test('assigns formatter to handlers without one', () => {
|
||||
class TestHandler extends Handler {
|
||||
emit(record: LogRecord) {}
|
||||
}
|
||||
const handler = new TestHandler();
|
||||
expect(handler.formatter).toBeNull();
|
||||
basicConfig({ handlers: [handler] });
|
||||
expect(handler.formatter).not.toBeNull();
|
||||
});
|
||||
|
||||
test('preserves existing formatter on handlers', () => {
|
||||
class TestHandler extends Handler {
|
||||
emit(record: LogRecord) {}
|
||||
}
|
||||
const handler = new TestHandler();
|
||||
const fmt = new Formatter({ fmt: '%(message)s' });
|
||||
handler.formatter = fmt;
|
||||
basicConfig({ handlers: [handler] });
|
||||
expect(handler.formatter).toBe(fmt);
|
||||
});
|
||||
});
|
||||
143
tests/unit/formatter.test.ts
Normal file
143
tests/unit/formatter.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import {expect, jest, test} from '@jest/globals';
|
||||
import { Formatter, DEFAULT_FORMATTER } from '../../src/formatter';
|
||||
import { LogRecord } from '../../src/log-record';
|
||||
import { DEBUG, WARNING, INFO } from '../../src/log-level';
|
||||
import { MyError } from '../../src/helper/error';
|
||||
|
||||
function makeRecord(level: number, msg: string): LogRecord {
|
||||
return new LogRecord('test.module', { level, msg });
|
||||
}
|
||||
|
||||
describe('Formatter', () => {
|
||||
describe('constructor', () => {
|
||||
test('uses default format when no options given', () => {
|
||||
const fmt = new Formatter();
|
||||
const record = makeRecord(WARNING, 'hello');
|
||||
const result = fmt.format(record);
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
test('accepts custom format string', () => {
|
||||
const fmt = new Formatter({ fmt: '%(levelname)s - %(message)s' });
|
||||
const record = makeRecord(DEBUG, 'debug msg');
|
||||
expect(fmt.format(record)).toBe('DEBUG - debug msg');
|
||||
});
|
||||
|
||||
test('throws on invalid style', () => {
|
||||
expect(() => new Formatter({ style: '{' })).toThrow('style must be one of');
|
||||
});
|
||||
});
|
||||
|
||||
describe('format', () => {
|
||||
test('substitutes %(name)s with logger scope', () => {
|
||||
const fmt = new Formatter({ fmt: '[%(name)s] %(message)s' });
|
||||
const record = makeRecord(INFO, 'test');
|
||||
expect(fmt.format(record)).toBe('[test.module] test');
|
||||
});
|
||||
|
||||
test('substitutes %(levelno)d with numeric level', () => {
|
||||
const fmt = new Formatter({ fmt: '%(levelno)d: %(message)s' });
|
||||
const record = makeRecord(WARNING, 'warn');
|
||||
expect(fmt.format(record)).toBe('30: warn');
|
||||
});
|
||||
|
||||
test('substitutes %(levelname)s with level name', () => {
|
||||
const fmt = new Formatter({ fmt: '%(levelname)s %(message)s' });
|
||||
const record = makeRecord(DEBUG, 'msg');
|
||||
expect(fmt.format(record)).toBe('DEBUG msg');
|
||||
});
|
||||
|
||||
test('handles multiple placeholders', () => {
|
||||
const fmt = new Formatter({
|
||||
fmt: '%(levelname)s:%(name)s:%(levelno)d:%(message)s'
|
||||
});
|
||||
const record = makeRecord(WARNING, 'multi');
|
||||
expect(fmt.format(record)).toBe('WARNING:test.module:30:multi');
|
||||
});
|
||||
|
||||
test('throws on unknown field', () => {
|
||||
const fmt = new Formatter({ fmt: '%(nonexistent)s' });
|
||||
const record = makeRecord(DEBUG, 'test');
|
||||
expect(() => fmt.format(record)).toThrow('formatting field not found');
|
||||
});
|
||||
|
||||
test('populates asctime when format uses it', () => {
|
||||
const fmt = new Formatter({
|
||||
fmt: '%(asctime)s %(message)s'
|
||||
});
|
||||
const record = makeRecord(INFO, 'timed');
|
||||
const result = fmt.format(record);
|
||||
expect(result).toContain('timed');
|
||||
expect(record.asctime.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('does not populate asctime when format does not use it', () => {
|
||||
const fmt = new Formatter({ fmt: '%(message)s' });
|
||||
const record = makeRecord(INFO, 'no time');
|
||||
fmt.format(record);
|
||||
expect(record.asctime).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
test('returns ISO8601-like string by default', () => {
|
||||
const fmt = new Formatter();
|
||||
const record = makeRecord(INFO, 'test');
|
||||
const result = fmt.formatTime(record);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test('uses custom datefmt with strftime tokens', () => {
|
||||
const fmt = new Formatter();
|
||||
const record = makeRecord(INFO, 'test');
|
||||
const result = fmt.formatTime(record, '%Y/%m/%d');
|
||||
expect(result).toMatch(/^\d{4}\/\d{2}\/\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatError', () => {
|
||||
test('returns stack trace when available', () => {
|
||||
const fmt = new Formatter();
|
||||
const err = new MyError('test error');
|
||||
const result = fmt.formatError(err);
|
||||
expect(result).toContain('test error');
|
||||
expect(result).toContain('\n');
|
||||
});
|
||||
|
||||
test('returns string representation when no stack', () => {
|
||||
const fmt = new Formatter();
|
||||
const err = new MyError('no stack');
|
||||
err.stack = undefined;
|
||||
const result = fmt.formatError(err);
|
||||
expect(result).toContain('no stack');
|
||||
});
|
||||
});
|
||||
|
||||
describe('usesTime', () => {
|
||||
test('returns true when format contains asctime', () => {
|
||||
const fmt = new Formatter({ fmt: '%(asctime)s %(message)s' });
|
||||
expect(fmt.usesTime()).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when format does not contain asctime', () => {
|
||||
const fmt = new Formatter({ fmt: '%(message)s' });
|
||||
expect(fmt.usesTime()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LogRecord.getMessage', () => {
|
||||
test('returns the message string', () => {
|
||||
const record = makeRecord(INFO, 'plain message');
|
||||
expect(record.getMessage()).toBe('plain message');
|
||||
});
|
||||
|
||||
test('substitutes %s args into message', () => {
|
||||
const record = new LogRecord('test', {
|
||||
level: INFO,
|
||||
msg: 'hello %s, you have %s items',
|
||||
args: ['world', '5'],
|
||||
});
|
||||
expect(record.getMessage()).toBe('hello world, you have 5 items');
|
||||
});
|
||||
});
|
||||
211
tests/unit/handler.test.ts
Normal file
211
tests/unit/handler.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import {expect, jest, test} from '@jest/globals';
|
||||
import {
|
||||
Handler,
|
||||
StreamHandler,
|
||||
ConsoleHandler,
|
||||
StderrHandler,
|
||||
FileHandler,
|
||||
} from '../../src/handler';
|
||||
import { Formatter, DEFAULT_FORMATTER } from '../../src/formatter';
|
||||
import { LogRecord } from '../../src/log-record';
|
||||
import { Writable } from '../../src/helper/stream';
|
||||
import { DEBUG, INFO, WARNING, ERROR, CRITICAL, NOTSET } from '../../src/log-level';
|
||||
|
||||
function makeRecord(level: number, msg: string): LogRecord {
|
||||
return new LogRecord('test', { level, msg });
|
||||
}
|
||||
|
||||
describe('Handler', () => {
|
||||
describe('constructor', () => {
|
||||
test('defaults to NOTSET level', () => {
|
||||
const h = new Handler();
|
||||
expect(h.level).toBe(NOTSET);
|
||||
});
|
||||
|
||||
test('accepts a level argument', () => {
|
||||
const h = new Handler(WARNING);
|
||||
expect(h.level).toBe(WARNING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('level setter', () => {
|
||||
test('sets the level without infinite recursion', () => {
|
||||
const h = new Handler();
|
||||
h.level = ERROR;
|
||||
expect(h.level).toBe(ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatter', () => {
|
||||
test('getter returns null when no formatter set', () => {
|
||||
const h = new Handler();
|
||||
expect(h.formatter).toBeNull();
|
||||
});
|
||||
|
||||
test('getter returns assigned formatter', () => {
|
||||
const h = new Handler();
|
||||
const fmt = new Formatter();
|
||||
h.formatter = fmt;
|
||||
expect(h.formatter).toBe(fmt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('format', () => {
|
||||
test('uses DEFAULT_FORMATTER when no formatter assigned', () => {
|
||||
const h = new Handler();
|
||||
const record = makeRecord(DEBUG, 'hello');
|
||||
const result = h.format(record);
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
test('returns a string', () => {
|
||||
const h = new Handler();
|
||||
const record = makeRecord(WARNING, 'test message');
|
||||
expect(typeof h.format(record)).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emit', () => {
|
||||
test('throws NotImplementedError on base class', () => {
|
||||
const h = new Handler();
|
||||
const record = makeRecord(DEBUG, 'hello');
|
||||
expect(() => h.emit(record)).toThrow('emit must be implemented');
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
test('sets closed to true', () => {
|
||||
const h = new Handler();
|
||||
expect(h.closed).toBe(false);
|
||||
h.close();
|
||||
expect(h.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleError', () => {
|
||||
test('does not throw', () => {
|
||||
const h = new Handler();
|
||||
const record = makeRecord(DEBUG, 'test');
|
||||
expect(() => h.handleError(record)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('StreamHandler', () => {
|
||||
test('writes formatted output to the stream', () => {
|
||||
const written: string[] = [];
|
||||
const stream: Writable = { write: (data: string) => { written.push(data) } };
|
||||
const h = new StreamHandler(stream);
|
||||
const record = makeRecord(WARNING, 'stream test');
|
||||
h.emit(record);
|
||||
expect(written.length).toBe(1);
|
||||
expect(written[0]).toContain('stream test');
|
||||
});
|
||||
|
||||
test('uses default stream when none provided', () => {
|
||||
const h = new StreamHandler();
|
||||
const record = makeRecord(DEBUG, 'default stream');
|
||||
expect(() => h.emit(record)).not.toThrow();
|
||||
});
|
||||
|
||||
test('calls handleError on emit failure', () => {
|
||||
const stream: Writable = {
|
||||
write: () => { throw new Error('write failed') }
|
||||
};
|
||||
const h = new StreamHandler(stream);
|
||||
const record = makeRecord(DEBUG, 'fail');
|
||||
expect(() => h.emit(record)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleHandler', () => {
|
||||
let origLog: typeof console.log;
|
||||
let origWarn: typeof console.warn;
|
||||
let origError: typeof console.error;
|
||||
let logged: string[];
|
||||
let warned: string[];
|
||||
let errored: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
logged = [];
|
||||
warned = [];
|
||||
errored = [];
|
||||
origLog = console.log;
|
||||
origWarn = console.warn;
|
||||
origError = console.error;
|
||||
console.log = (...args: any[]) => { logged.push(args.join(' ')) };
|
||||
console.warn = (...args: any[]) => { warned.push(args.join(' ')) };
|
||||
console.error = (...args: any[]) => { errored.push(args.join(' ')) };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
});
|
||||
|
||||
test('uses console.error for ERROR level', () => {
|
||||
const h = new ConsoleHandler();
|
||||
h.emit(makeRecord(ERROR, 'error msg'));
|
||||
expect(errored.length).toBe(1);
|
||||
expect(errored[0]).toContain('error msg');
|
||||
});
|
||||
|
||||
test('uses console.error for CRITICAL level', () => {
|
||||
const h = new ConsoleHandler();
|
||||
h.emit(makeRecord(CRITICAL, 'critical msg'));
|
||||
expect(errored.length).toBe(1);
|
||||
expect(errored[0]).toContain('critical msg');
|
||||
});
|
||||
|
||||
test('uses console.warn for WARNING level', () => {
|
||||
const h = new ConsoleHandler();
|
||||
h.emit(makeRecord(WARNING, 'warn msg'));
|
||||
expect(warned.length).toBe(1);
|
||||
expect(warned[0]).toContain('warn msg');
|
||||
});
|
||||
|
||||
test('uses console.log for INFO level', () => {
|
||||
const h = new ConsoleHandler();
|
||||
h.emit(makeRecord(INFO, 'info msg'));
|
||||
expect(logged.length).toBe(1);
|
||||
expect(logged[0]).toContain('info msg');
|
||||
});
|
||||
|
||||
test('uses console.log for DEBUG level', () => {
|
||||
const h = new ConsoleHandler();
|
||||
h.emit(makeRecord(DEBUG, 'debug msg'));
|
||||
expect(logged.length).toBe(1);
|
||||
expect(logged[0]).toContain('debug msg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StderrHandler', () => {
|
||||
test('accepts a level', () => {
|
||||
const h = new StderrHandler(ERROR);
|
||||
expect(h.level).toBe(ERROR);
|
||||
});
|
||||
|
||||
test('emits via console.error', () => {
|
||||
const errored: string[] = [];
|
||||
const origError = console.error;
|
||||
console.error = (...args: any[]) => { errored.push(args.join(' ')) };
|
||||
try {
|
||||
const h = new StderrHandler(DEBUG);
|
||||
h.emit(makeRecord(WARNING, 'stderr test'));
|
||||
expect(errored.length).toBe(1);
|
||||
expect(errored[0]).toContain('stderr test');
|
||||
}
|
||||
finally {
|
||||
console.error = origError;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileHandler', () => {
|
||||
test('throws NotImplementedError on construction', () => {
|
||||
expect(() => new FileHandler({
|
||||
filename: 'test.log'
|
||||
})).toThrow('not available in browser');
|
||||
});
|
||||
});
|
||||
158
tests/unit/local-storage-handler.test.ts
Normal file
158
tests/unit/local-storage-handler.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import {expect, jest, test, beforeEach, afterEach} from '@jest/globals';
|
||||
import { LocalStorageHandler } from '../../src/handler';
|
||||
import { LogRecord } from '../../src/log-record';
|
||||
import { DEBUG, WARNING, ERROR } from '../../src/log-level';
|
||||
|
||||
// mock localStorage for Node/Jest environment
|
||||
const store: {[key: string]: string} = {};
|
||||
const localStorageMock = {
|
||||
getItem: (key: string): string | null => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
};
|
||||
|
||||
function clearStore() {
|
||||
for (const key of Object.keys(store)) { delete store[key] }
|
||||
}
|
||||
|
||||
function makeRecord(level: number, msg: string): LogRecord {
|
||||
return new LogRecord('test', { level, msg });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearStore();
|
||||
(globalThis as any).localStorage = localStorageMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearStore();
|
||||
delete (globalThis as any).localStorage;
|
||||
});
|
||||
|
||||
describe('LocalStorageHandler', () => {
|
||||
describe('constructor', () => {
|
||||
test('uses default key and limits', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
expect(h.getEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
test('throws when localStorage is unavailable', () => {
|
||||
delete (globalThis as any).localStorage;
|
||||
expect(() => new LocalStorageHandler()).toThrow(
|
||||
'requires a browser environment'
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts custom options', () => {
|
||||
const h = new LocalStorageHandler({
|
||||
key: 'custom-log',
|
||||
maxEntries: 50,
|
||||
maxBytes: 4096,
|
||||
});
|
||||
h.emit(makeRecord(DEBUG, 'test'));
|
||||
expect(store['custom-log']).toBeDefined();
|
||||
expect(store['esm-logging']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emit', () => {
|
||||
test('stores formatted log entry', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
h.emit(makeRecord(WARNING, 'hello'));
|
||||
const entries = h.getEntries();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0]).toContain('hello');
|
||||
});
|
||||
|
||||
test('appends multiple entries', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
h.emit(makeRecord(DEBUG, 'first'));
|
||||
h.emit(makeRecord(DEBUG, 'second'));
|
||||
h.emit(makeRecord(DEBUG, 'third'));
|
||||
expect(h.getEntries().length).toBe(3);
|
||||
});
|
||||
|
||||
test('persists across handler instances with same key', () => {
|
||||
const h1 = new LocalStorageHandler({ key: 'shared' });
|
||||
h1.emit(makeRecord(DEBUG, 'from h1'));
|
||||
|
||||
const h2 = new LocalStorageHandler({ key: 'shared' });
|
||||
const entries = h2.getEntries();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0]).toContain('from h1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotation by entry count', () => {
|
||||
test('discards oldest entries when maxEntries exceeded', () => {
|
||||
const h = new LocalStorageHandler({ maxEntries: 3 });
|
||||
h.emit(makeRecord(DEBUG, 'msg-1'));
|
||||
h.emit(makeRecord(DEBUG, 'msg-2'));
|
||||
h.emit(makeRecord(DEBUG, 'msg-3'));
|
||||
h.emit(makeRecord(DEBUG, 'msg-4'));
|
||||
|
||||
const entries = h.getEntries();
|
||||
expect(entries.length).toBe(3);
|
||||
expect(entries[0]).toContain('msg-2');
|
||||
expect(entries[2]).toContain('msg-4');
|
||||
});
|
||||
|
||||
test('maxEntries of 1 keeps only the latest', () => {
|
||||
const h = new LocalStorageHandler({ maxEntries: 1 });
|
||||
h.emit(makeRecord(DEBUG, 'old'));
|
||||
h.emit(makeRecord(DEBUG, 'new'));
|
||||
|
||||
const entries = h.getEntries();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0]).toContain('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotation by byte size', () => {
|
||||
test('discards oldest entries when maxBytes exceeded', () => {
|
||||
const h = new LocalStorageHandler({ maxEntries: 0, maxBytes: 100 });
|
||||
// emit entries until we exceed the limit
|
||||
for (let i = 0; i < 20; i++) {
|
||||
h.emit(makeRecord(DEBUG, `message-${i}`));
|
||||
}
|
||||
|
||||
const entries = h.getEntries();
|
||||
const serialized = JSON.stringify(entries);
|
||||
expect(serialized.length).toBeLessThanOrEqual(100);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntries', () => {
|
||||
test('returns empty array when no entries', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
expect(h.getEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns empty array on corrupt data', () => {
|
||||
store['esm-logging'] = 'not valid json{{{';
|
||||
const h = new LocalStorageHandler();
|
||||
expect(h.getEntries()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearEntries', () => {
|
||||
test('removes all stored entries', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
h.emit(makeRecord(DEBUG, 'to be cleared'));
|
||||
expect(h.getEntries().length).toBe(1);
|
||||
|
||||
h.clearEntries();
|
||||
expect(h.getEntries()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
test('sets closed to true', () => {
|
||||
const h = new LocalStorageHandler();
|
||||
expect(h.closed).toBe(false);
|
||||
h.close();
|
||||
expect(h.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import {expect, jest, test} from '@jest/globals';
|
||||
import * as log_level from '../src/log-level';
|
||||
import * as log_level from '../../src/log-level';
|
||||
|
||||
describe('Logger', () => {
|
||||
it('can be instantiated', () => {
|
||||
209
tests/unit/logger.test.ts
Normal file
209
tests/unit/logger.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import {expect, jest, test} from '@jest/globals';
|
||||
import { Logger, RootLogger, ROOT } from '../../src/logger';
|
||||
import { Handler } from '../../src/handler';
|
||||
import { LogRecord } from '../../src/log-record';
|
||||
import { Formatter } from '../../src/formatter';
|
||||
import { Manager } from '../../src/manager';
|
||||
import {
|
||||
DEBUG,
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR,
|
||||
CRITICAL,
|
||||
NOTSET,
|
||||
} from '../../src/log-level';
|
||||
import { MyError } from '../../src/helper/error';
|
||||
|
||||
describe('Logger', () => {
|
||||
describe('constructor', () => {
|
||||
test('sets scope and default level', () => {
|
||||
const logger = new Logger('test');
|
||||
expect(logger.scope).toBe('test');
|
||||
expect(logger.level).toBe(NOTSET);
|
||||
});
|
||||
|
||||
test('accepts explicit level', () => {
|
||||
const logger = new Logger('test', WARNING);
|
||||
expect(logger.level).toBe(WARNING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLevel', () => {
|
||||
test('changes the level', () => {
|
||||
const logger = new Logger('test');
|
||||
logger.setLevel(ERROR);
|
||||
expect(logger.level).toBe(ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectiveLevel', () => {
|
||||
test('returns own level when set', () => {
|
||||
const logger = new Logger('test', WARNING);
|
||||
expect(logger.getEffectiveLevel()).toBe(WARNING);
|
||||
});
|
||||
|
||||
test('returns NOTSET when no level set and no parent', () => {
|
||||
const logger = new Logger('test');
|
||||
expect(logger.getEffectiveLevel()).toBe(NOTSET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabledFor', () => {
|
||||
test('returns true when level meets effective level', () => {
|
||||
const logger = new Logger('test', DEBUG);
|
||||
expect(logger.isEnabledFor(WARNING)).toBe(true);
|
||||
expect(logger.isEnabledFor(DEBUG)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when level is below effective level', () => {
|
||||
const logger = new Logger('test', WARNING);
|
||||
expect(logger.isEnabledFor(DEBUG)).toBe(false);
|
||||
expect(logger.isEnabledFor(INFO)).toBe(false);
|
||||
});
|
||||
|
||||
test('caches results for repeated calls', () => {
|
||||
const logger = new Logger('test', WARNING);
|
||||
const first = logger.isEnabledFor(DEBUG);
|
||||
const second = logger.isEnabledFor(DEBUG);
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
test('respects manager disable level', () => {
|
||||
const root = new RootLogger(WARNING);
|
||||
const manager = new Manager(root);
|
||||
const logger = manager.getLogger('test');
|
||||
logger.setLevel(DEBUG);
|
||||
logger.manager = manager;
|
||||
manager.disable = ERROR;
|
||||
logger.clear();
|
||||
expect(logger.isEnabledFor(DEBUG)).toBe(false);
|
||||
expect(logger.isEnabledFor(WARNING)).toBe(false);
|
||||
expect(logger.isEnabledFor(CRITICAL)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manager property', () => {
|
||||
test('starts as null', () => {
|
||||
const logger = new Logger('test');
|
||||
expect(logger.manager).toBeNull();
|
||||
});
|
||||
|
||||
test('can be assigned once', () => {
|
||||
const logger = new Logger('test');
|
||||
const root = new RootLogger(WARNING);
|
||||
const manager = new Manager(root);
|
||||
logger.manager = manager;
|
||||
expect(logger.manager).toBe(manager);
|
||||
});
|
||||
|
||||
test('throws on second assignment', () => {
|
||||
const logger = new Logger('test');
|
||||
const root = new RootLogger(WARNING);
|
||||
const manager = new Manager(root);
|
||||
logger.manager = manager;
|
||||
expect(() => { logger.manager = manager }).toThrow('logger can only be assigned to manager once');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addHandler / removeHandler', () => {
|
||||
test('adds and removes handlers', () => {
|
||||
const logger = new Logger('test');
|
||||
const handler = new Handler(DEBUG);
|
||||
expect(logger.handlers.length).toBe(0);
|
||||
logger.addHandler(handler);
|
||||
expect(logger.handlers.length).toBe(1);
|
||||
logger.removeHandler(handler);
|
||||
});
|
||||
|
||||
test('does not add duplicate handlers', () => {
|
||||
const logger = new Logger('test');
|
||||
const handler = new Handler(DEBUG);
|
||||
logger.addHandler(handler);
|
||||
logger.addHandler(handler);
|
||||
expect(logger.handlers.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('level methods', () => {
|
||||
let logger: Logger;
|
||||
let emitted: LogRecord[];
|
||||
|
||||
class TestHandler extends Handler {
|
||||
emit(record: LogRecord) { emitted.push(record) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new Logger('test', DEBUG);
|
||||
emitted = [];
|
||||
logger.addHandler(new TestHandler(DEBUG));
|
||||
});
|
||||
|
||||
test('debug emits at DEBUG level', () => {
|
||||
logger.debug('msg');
|
||||
expect(emitted.length).toBe(1);
|
||||
expect(emitted[0].levelno).toBe(DEBUG);
|
||||
});
|
||||
|
||||
test('info emits at INFO level', () => {
|
||||
logger.info('msg');
|
||||
expect(emitted.length).toBe(1);
|
||||
expect(emitted[0].levelno).toBe(INFO);
|
||||
});
|
||||
|
||||
test('warning emits at WARNING level', () => {
|
||||
logger.warning('msg');
|
||||
expect(emitted.length).toBe(1);
|
||||
expect(emitted[0].levelno).toBe(WARNING);
|
||||
});
|
||||
|
||||
test('error emits at ERROR level', () => {
|
||||
logger.error('msg');
|
||||
expect(emitted.length).toBe(1);
|
||||
expect(emitted[0].levelno).toBe(ERROR);
|
||||
});
|
||||
|
||||
test('critical emits at CRITICAL level', () => {
|
||||
logger.critical('msg');
|
||||
expect(emitted.length).toBe(1);
|
||||
expect(emitted[0].levelno).toBe(CRITICAL);
|
||||
});
|
||||
|
||||
test('level methods respect effective level', () => {
|
||||
logger.setLevel(ERROR);
|
||||
logger.clear();
|
||||
logger.debug('no');
|
||||
logger.info('no');
|
||||
logger.warning('no');
|
||||
expect(emitted.length).toBe(0);
|
||||
logger.error('yes');
|
||||
logger.critical('yes');
|
||||
expect(emitted.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeRecord', () => {
|
||||
test('throws KeyError when extra overwrites existing LogRecord key', () => {
|
||||
const logger = new Logger('test', DEBUG);
|
||||
expect(() => {
|
||||
logger.debug('test', {
|
||||
excInfo: null,
|
||||
extra: { scope: 'override' },
|
||||
stackInfo: false,
|
||||
stackLevel: 1,
|
||||
});
|
||||
}).toThrow('attempt to overwrite scope in LogRecord');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RootLogger', () => {
|
||||
test('has scope "root"', () => {
|
||||
const root = new RootLogger(WARNING);
|
||||
expect(root.scope).toBe('root');
|
||||
});
|
||||
|
||||
test('accepts a level', () => {
|
||||
const root = new RootLogger(ERROR);
|
||||
expect(root.level).toBe(ERROR);
|
||||
});
|
||||
});
|
||||
209
tests/unit/manager.test.ts
Normal file
209
tests/unit/manager.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import {expect, jest, test} from '@jest/globals';
|
||||
import { Manager } from '../../src/manager';
|
||||
import { Logger, RootLogger } from '../../src/logger';
|
||||
import { Handler } from '../../src/handler';
|
||||
import { LogRecord } from '../../src/log-record';
|
||||
import { WARNING, DEBUG, INFO, NOTSET } from '../../src/log-level';
|
||||
|
||||
function makeManager(): Manager {
|
||||
return new Manager(new RootLogger(WARNING));
|
||||
}
|
||||
|
||||
describe('Manager', () => {
|
||||
describe('constructor', () => {
|
||||
test('stores the root logger', () => {
|
||||
const root = new RootLogger(WARNING);
|
||||
const manager = new Manager(root);
|
||||
expect(manager.root).toBe(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogger', () => {
|
||||
test('returns a Logger instance', () => {
|
||||
const manager = makeManager();
|
||||
const logger = manager.getLogger('app');
|
||||
expect(logger).toBeInstanceOf(Logger);
|
||||
expect(logger.scope).toBe('app');
|
||||
});
|
||||
|
||||
test('returns the same logger for the same scope', () => {
|
||||
const manager = makeManager();
|
||||
const a = manager.getLogger('app');
|
||||
const b = manager.getLogger('app');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test('returns different loggers for different scopes', () => {
|
||||
const manager = makeManager();
|
||||
const a = manager.getLogger('app');
|
||||
const b = manager.getLogger('lib');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test('sets parent to root for top-level loggers', () => {
|
||||
const manager = makeManager();
|
||||
const logger = manager.getLogger('app');
|
||||
expect(logger.parent).toBe(manager.root);
|
||||
});
|
||||
|
||||
test('sets parent to existing parent logger', () => {
|
||||
const manager = makeManager();
|
||||
const parent = manager.getLogger('app');
|
||||
const child = manager.getLogger('app.module');
|
||||
expect(child.parent).toBe(parent);
|
||||
});
|
||||
|
||||
test('establishes hierarchy for deeply nested loggers', () => {
|
||||
const manager = makeManager();
|
||||
const top = manager.getLogger('a');
|
||||
const mid = manager.getLogger('a.b');
|
||||
const leaf = manager.getLogger('a.b.c');
|
||||
expect(leaf.parent).toBe(mid);
|
||||
expect(mid.parent).toBe(top);
|
||||
expect(top.parent).toBe(manager.root);
|
||||
});
|
||||
|
||||
test('creates placeholders for intermediate loggers', () => {
|
||||
const manager = makeManager();
|
||||
const leaf = manager.getLogger('a.b.c');
|
||||
expect(leaf.parent).toBe(manager.root);
|
||||
|
||||
const mid = manager.getLogger('a.b');
|
||||
expect(leaf.parent).toBe(mid);
|
||||
expect(mid.parent).toBe(manager.root);
|
||||
});
|
||||
|
||||
test('fixes up children when intermediate logger is created', () => {
|
||||
const manager = makeManager();
|
||||
const leaf = manager.getLogger('a.b.c');
|
||||
const top = manager.getLogger('a');
|
||||
|
||||
expect(top.parent).toBe(manager.root);
|
||||
|
||||
const mid = manager.getLogger('a.b');
|
||||
expect(leaf.parent).toBe(mid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable', () => {
|
||||
test('defaults to 0', () => {
|
||||
const manager = makeManager();
|
||||
expect(manager.disable).toBe(0);
|
||||
});
|
||||
|
||||
test('can be set to a level', () => {
|
||||
const manager = makeManager();
|
||||
manager.disable = WARNING;
|
||||
expect(manager.disable).toBe(WARNING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loggerClass', () => {
|
||||
test('accepts Logger subclass', () => {
|
||||
const manager = makeManager();
|
||||
class CustomLogger extends Logger {}
|
||||
expect(() => { manager.loggerClass = CustomLogger as any }).not.toThrow();
|
||||
});
|
||||
|
||||
test('rejects non-Logger class', () => {
|
||||
const manager = makeManager();
|
||||
class NotALogger {}
|
||||
expect(() => {
|
||||
manager.loggerClass = NotALogger as any;
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('accepts Logger itself', () => {
|
||||
const manager = makeManager();
|
||||
expect(() => { manager.loggerClass = Logger as any }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogger - manager assignment', () => {
|
||||
test('sets manager on newly created loggers', () => {
|
||||
const manager = makeManager();
|
||||
const logger = manager.getLogger('app');
|
||||
expect(logger.manager).toBe(manager);
|
||||
});
|
||||
|
||||
test('sets manager on loggers created from placeholders', () => {
|
||||
const manager = makeManager();
|
||||
manager.getLogger('a.b.c');
|
||||
const mid = manager.getLogger('a.b');
|
||||
expect(mid.manager).toBe(manager);
|
||||
});
|
||||
|
||||
test('isEnabledFor respects manager.disable via getLogger', () => {
|
||||
const manager = makeManager();
|
||||
const logger = manager.getLogger('app');
|
||||
logger.setLevel(DEBUG);
|
||||
|
||||
expect(logger.isEnabledFor(DEBUG)).toBe(true);
|
||||
|
||||
manager.disable = WARNING;
|
||||
logger.clear();
|
||||
|
||||
expect(logger.isEnabledFor(DEBUG)).toBe(false);
|
||||
expect(logger.isEnabledFor(WARNING)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('propagation through hierarchy', () => {
|
||||
test('child logger propagates records to parent handler', () => {
|
||||
const manager = makeManager();
|
||||
const parent = manager.getLogger('app');
|
||||
const child = manager.getLogger('app.module');
|
||||
|
||||
parent.setLevel(DEBUG);
|
||||
child.setLevel(DEBUG);
|
||||
|
||||
const emitted: string[] = [];
|
||||
const handler = new (class extends Handler {
|
||||
emit(record: LogRecord) {
|
||||
emitted.push(record.msg);
|
||||
}
|
||||
})(DEBUG);
|
||||
|
||||
parent.addHandler(handler);
|
||||
child.info('propagated message');
|
||||
|
||||
expect(emitted).toContain('propagated message');
|
||||
});
|
||||
|
||||
test('child does not propagate to unrelated logger', () => {
|
||||
const manager = makeManager();
|
||||
const unrelated = manager.getLogger('other');
|
||||
const child = manager.getLogger('app.module');
|
||||
|
||||
unrelated.setLevel(DEBUG);
|
||||
child.setLevel(DEBUG);
|
||||
|
||||
const emitted: string[] = [];
|
||||
const handler = new (class extends Handler {
|
||||
emit(record: LogRecord) {
|
||||
emitted.push(record.msg);
|
||||
}
|
||||
})(DEBUG);
|
||||
|
||||
unrelated.addHandler(handler);
|
||||
child.info('should not reach');
|
||||
|
||||
expect(emitted).not.toContain('should not reach');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
test('clears logger caches', () => {
|
||||
const manager = makeManager();
|
||||
const logger = manager.getLogger('test');
|
||||
logger.isEnabledFor(DEBUG);
|
||||
manager.clear();
|
||||
});
|
||||
|
||||
test('does not throw when placeholders exist', () => {
|
||||
const manager = makeManager();
|
||||
manager.getLogger('a.b.c');
|
||||
expect(() => manager.clear()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
vendor/tiara-gitflow-spec
vendored
Submodule
1
vendor/tiara-gitflow-spec
vendored
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit ee93479edce1da28f4abf68a362427f8d3134f80
|
||||
Loading…
Add table
Add a link
Reference in a new issue