Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74465875cb | ||
|
|
182d41ba8e | ||
|
|
74409928f6 | ||
|
|
f30731f3b8 | ||
|
|
23c56e8d1c | ||
|
|
c2dc339a38 | ||
|
|
bb5442f808 | ||
|
|
49954b75a6 | ||
|
|
a5e2df99ce | ||
|
|
5a05a41e59 | ||
|
|
163a24646a | ||
|
|
c3368a3595 |
25 changed files with 322 additions and 3598 deletions
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
[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,89 +1,33 @@
|
||||||
# esm-logging
|
# esm-logging
|
||||||
|
|
||||||
A quasi-port of the Python standard library
|
> This README is a stub. Working on it. Currently stabilizing the build
|
||||||
[logging](https://docs.python.org/3/library/logging.html) module to
|
environment after that I'll make it nice around here.
|
||||||
ECMAScript. Browser-compatible, zero dependencies.
|
|
||||||
|
|
||||||
## Why?
|
A quasi-port of the Python standard library logging module to ECMAScript.
|
||||||
|
|
||||||
Logging is important. It is important for debugging purposes, leading to
|
# Why?
|
||||||
faster and more resilient development, for traceability leading to better
|
|
||||||
security. Most logging libraries I've discovered didn't satisfy me, introduced
|
First of, because logging is important. It is important for debugging purposes,
|
||||||
weird concepts and all in all just weren't great. Other programming language
|
leading to faster and more resilient development, for traceability leading to
|
||||||
ecosystems offer way nicer logging facilities. Take Rust for example, or...
|
better security. Most logging libraries I've discovered didn't satisfy me,
|
||||||
Python! Python has PEP, giving it a very structured approach towards
|
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
|
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
|
([PEP 282](https://peps.python.org/pep-0282/)). Python's logging facilities are
|
||||||
are implemented by the [logging](https://docs.python.org/3/library/logging.html)
|
implemented by the [logging]() module, which is part of the standard library and
|
||||||
module, which is part of the standard library and has been since 2002. It was
|
has been since 2002. It was originally authored by Vinay Sajip
|
||||||
originally authored by Vinay Sajip.
|
|
||||||
|
|
||||||
## Installation
|
# Roadmap
|
||||||
|
|
||||||
```bash
|
- do a quasi-port of the logging module with minimal amount of adaption
|
||||||
npm install @administratrix/esm-logging
|
- add documentation
|
||||||
```
|
- add support for asynchronous calls
|
||||||
|
- implement Open Cybersecurity Framework (OCSF) formatter
|
||||||
|
- implement (Browser) local storage handler as a replacement for file handler
|
||||||
|
|
||||||
## Quick start
|
# Usage
|
||||||
|
|
||||||
```javascript
|
For the time being, please check out my [CI
|
||||||
import * as logging from '@administratrix/esm-logging';
|
service](https://bitbucket.org/byteb4rb1e/esm-logging/pipelines), for an idea on
|
||||||
|
how to build this.
|
||||||
// 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,5 +1,51 @@
|
||||||
--ISSUE
|
# TODO List for esm-logging
|
||||||
Content-Type: application/issue
|
|
||||||
|
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
|
||||||
|
|
||||||
ID: 1
|
ID: 1
|
||||||
Type: feature
|
Type: feature
|
||||||
|
|
@ -7,14 +53,12 @@ Title: string formatting utilities
|
||||||
Status: in-progress
|
Status: in-progress
|
||||||
Priority: high
|
Priority: high
|
||||||
Created: 2025-05-01
|
Created: 2025-05-01
|
||||||
Relationships:
|
|
||||||
Description: implement utilities for formatting strings. The formatting should
|
Description: implement utilities for formatting strings. The formatting should
|
||||||
be inspired by Python 3K PEP 3101 in addition to their standard
|
be inspired by Python 3K PEP 3101 in addition to their standard
|
||||||
library utilities starting from ver. 3.7. Optimizations should
|
library utilities starting from ver. 3.7. Optimizations should
|
||||||
focus on V8 support.
|
focus on V8 support.
|
||||||
|
|
||||||
--ISSUE
|
---
|
||||||
Content-Type: application/issue
|
|
||||||
|
|
||||||
ID: 2
|
ID: 2
|
||||||
Type: feature
|
Type: feature
|
||||||
|
|
@ -22,193 +66,10 @@ Title: describe development workflow in CONTRIBUTING.md
|
||||||
Status: open
|
Status: open
|
||||||
Priority: medium
|
Priority: medium
|
||||||
Created: 2025-05-01
|
Created: 2025-05-01
|
||||||
Relationships:
|
|
||||||
Description: It's a good idea to describe the development workflow, including
|
Description: It's a good idea to describe the development workflow, including
|
||||||
branching strategies earlier on, so that if someone is interested
|
branching strategies earlier on, so that if someone is interested
|
||||||
in forking, they can pick up right away. It's not meant for
|
in forking, they can pick up right away. It's not meant for
|
||||||
contributions though. I'm currently not interested in external
|
contributions though. I'm currently not interested in external
|
||||||
contributions.
|
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,333 +1 @@
|
||||||
# esm-logging user guide
|
The doc/README.md
|
||||||
|
|
||||||
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,216 +1,37 @@
|
||||||
# Logging cookbook
|
# Using logging in multiple modules
|
||||||
|
|
||||||
Recipes and patterns for common logging tasks with `@administratrix/esm-logging`.
|
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:
|
||||||
|
|
||||||
## Using logging across multiple modules
|
``javascript
|
||||||
|
import * as logging from 'eslib/logging';
|
||||||
|
import * as my_module from './my_module';
|
||||||
|
|
||||||
`getLogger` returns the same logger instance for a given scope name across
|
// create logger with 'spam_application'
|
||||||
your entire application. Configure handlers on a parent logger and create
|
var logger = logging.getLogger('spam_application');
|
||||||
child loggers in each module:
|
logger.setLevel(logging.DEBUG);
|
||||||
|
|
||||||
```javascript
|
// create file handler which logs even debug messages
|
||||||
// main.js
|
var fh = logging.FileHandler('spam.log')
|
||||||
import * as logging from '@administratrix/esm-logging';
|
fh.setLevel(logging.DEBUG);
|
||||||
import { ConsoleHandler } from '@administratrix/esm-logging/src/handler';
|
|
||||||
import { Formatter } from '@administratrix/esm-logging/src/formatter';
|
|
||||||
import { doWork } from './worker.js';
|
|
||||||
|
|
||||||
const logger = logging.manager.MANAGER.getLogger('myapp');
|
// create console handler with a higher log level
|
||||||
logger.setLevel(logging.log_level.DEBUG);
|
var ch = logging.StreamHandler();
|
||||||
|
ch.setLevel(logging.ERROR);
|
||||||
|
|
||||||
const handler = new ConsoleHandler();
|
// create formatter and add it to the handlers
|
||||||
handler.level = logging.log_level.DEBUG;
|
var formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s');
|
||||||
handler.formatter = new Formatter({
|
fh.setFormatter(formatter);
|
||||||
fmt: '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
ch.setFormatter(formatter);
|
||||||
});
|
|
||||||
logger.addHandler(handler);
|
|
||||||
|
|
||||||
logger.info('Application starting');
|
// add the handlers to the logger
|
||||||
doWork();
|
logger.addHandler(fh);
|
||||||
logger.info('Application finished');
|
logger.addHandler(ch);
|
||||||
|
|
||||||
|
logger.info('creating an instance of auxiliary_module.Auxiliary')
|
||||||
```
|
```
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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/unit']
|
roots: ['./tests']
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1250
package-lock.json
generated
1250
package-lock.json
generated
File diff suppressed because it is too large
Load diff
20
package.json
20
package.json
|
|
@ -3,15 +3,14 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "port of Python standard library logging module",
|
"description": "port of Python standard library logging module",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.11.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build/debug": "tsc -p tsconfig.debug.json",
|
"test": "jest",
|
||||||
"build/release": "tsc",
|
"build": "npm run build:release",
|
||||||
"build/doc": "typedoc",
|
"build:release": "tsc",
|
||||||
"dist": "ts-node -P tsconfig.node.json scripts/npm-pack.ts build/release dist",
|
"build:debug": "tsc -p tsconfig.debug.json",
|
||||||
"test-reports/unit": "jest"
|
"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"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
@ -20,16 +19,13 @@
|
||||||
"author": "Tiara Rodney",
|
"author": "Tiara Rodney",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"eslint": "^9.39.4",
|
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"ts-jest": "^29.3.2",
|
"ts-jest": "^29.3.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typedoc": "^0.28.3",
|
"typedoc": "^0.28.3",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3"
|
||||||
"typescript-eslint": "^8.57.0"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|
|
||||||
|
|
@ -121,8 +121,8 @@ export function basicConfig(options: BasicConfigOptions) {
|
||||||
const filename = options.filename ?? null;
|
const filename = options.filename ?? null;
|
||||||
const stream = options.stream ?? null;
|
const stream = options.stream ?? null;
|
||||||
const filemode = options.filemode ?? 'a';
|
const filemode = options.filemode ?? 'a';
|
||||||
const dateformat = options.datefmt;
|
const dateformat = options.filemode ?? null;
|
||||||
const style = options.style ?? '%';
|
const style = options.filemode ?? '%';
|
||||||
const level = options.level ?? null;
|
const level = options.level ?? null;
|
||||||
|
|
||||||
if (!Object.keys(STYLES).includes(style)) {
|
if (!Object.keys(STYLES).includes(style)) {
|
||||||
|
|
@ -139,54 +139,64 @@ export function basicConfig(options: BasicConfigOptions) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (MANAGER.root.handlers.length > 0 && !force) {
|
if (handlers.length == 0) {
|
||||||
return;
|
if (handlers === null && stream && filename) {
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
throw new ValueError(
|
||||||
"'stream' and 'filename' should not be specified together"
|
"'stream' and 'filename' should not be specified together"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filename) {
|
else if (stream || filename) {
|
||||||
if (filemode.match('b')) { errors = undefined }
|
throw new ValueError(
|
||||||
else { encoding = 'utf-8' }
|
"'stream' or 'filename' should not be specified together" +
|
||||||
|
"with 'handlers'"
|
||||||
handlers = [new FileHandler({
|
);
|
||||||
filename: filename,
|
|
||||||
filemode: filemode,
|
|
||||||
encoding: encoding,
|
|
||||||
errors: errors
|
|
||||||
})];
|
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
handlers = [new StreamHandler(stream)];
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,12 +10,6 @@ export interface PercentFormatterStyleOptions {
|
||||||
defaults: {[key: string]: any};
|
defaults: {[key: string]: any};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* %-style formatting for log records.
|
|
||||||
*
|
|
||||||
* Substitutes %(name)s, %(name)d, %(name)f placeholders from record
|
|
||||||
* attributes.
|
|
||||||
*/
|
|
||||||
class PercentFormatterStyle {
|
class PercentFormatterStyle {
|
||||||
public static defaultFormat = '%(message)s';
|
public static defaultFormat = '%(message)s';
|
||||||
public static asctimeFormat = '%(asctime)s';
|
public static asctimeFormat = '%(asctime)s';
|
||||||
|
|
@ -23,7 +17,7 @@ class PercentFormatterStyle {
|
||||||
public static validationPattern =
|
public static validationPattern =
|
||||||
/%\(\w+\)[#0+ -]*(\*|\d+)?(\.(\*|\d+))?[diouxefgcrsa%]/;
|
/%\(\w+\)[#0+ -]*(\*|\d+)?(\.(\*|\d+))?[diouxefgcrsa%]/;
|
||||||
|
|
||||||
public fmt: string;
|
private fmt: string;
|
||||||
private defaults: {[key: string]: any};
|
private defaults: {[key: string]: any};
|
||||||
|
|
||||||
constructor(options: PercentFormatterStyleOptions) {
|
constructor(options: PercentFormatterStyleOptions) {
|
||||||
|
|
@ -32,7 +26,7 @@ class PercentFormatterStyle {
|
||||||
}
|
}
|
||||||
|
|
||||||
usesTime(): boolean {
|
usesTime(): boolean {
|
||||||
return this.fmt.indexOf(PercentFormatterStyle.asctimeSearch) >= 0;
|
return this.fmt.match(PercentFormatterStyle.asctimeFormat) ? true : false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -48,32 +42,12 @@ class PercentFormatterStyle {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _format(record: LogRecord): string {
|
protected _format(record: LogRecord): string {
|
||||||
const values: {[key: string]: any} = {
|
var defaults = this.defaults;
|
||||||
...this.defaults,
|
var values: {[key: string]: any}|null;
|
||||||
...(record as unknown as {[key: string]: any}),
|
if (defaults) { values = {...this.defaults, ...Object.entries(record)} }
|
||||||
};
|
else { values = Object.entries(record) }
|
||||||
|
//TODO: implement formatting
|
||||||
return this.fmt.replace(
|
return 'would do some formatting';
|
||||||
/%\((\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 {
|
format(record: LogRecord): string {
|
||||||
|
|
@ -81,13 +55,12 @@ class PercentFormatterStyle {
|
||||||
return this._format(record)
|
return this._format(record)
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
if (e instanceof ValueError) { throw e }
|
|
||||||
throw new ValueError(`formatting field not found in record: ${e}`)
|
throw new ValueError(`formatting field not found in record: ${e}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASIC_FORMAT = '%(levelname)s:%(name)s:%(message)s';
|
const BASIC_FORMAT = '%(level)s:%(name)s:%(message)s';
|
||||||
|
|
||||||
export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOptions): PercentFormatterStyle}, string]} = {
|
export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOptions): PercentFormatterStyle}, string]} = {
|
||||||
'%': [PercentFormatterStyle, BASIC_FORMAT],
|
'%': [PercentFormatterStyle, BASIC_FORMAT],
|
||||||
|
|
@ -95,7 +68,7 @@ export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOption
|
||||||
|
|
||||||
export interface FormatterOptions {
|
export interface FormatterOptions {
|
||||||
fmt?: string
|
fmt?: string
|
||||||
datefmt?: string
|
datefmt?: any
|
||||||
style?: string
|
style?: string
|
||||||
validate?: boolean
|
validate?: boolean
|
||||||
defaults?: {[key: string]: any}
|
defaults?: {[key: string]: any}
|
||||||
|
|
@ -122,26 +95,53 @@ export interface FormatterOptions {
|
||||||
* WARNING, ERROR, CRITICAL)
|
* WARNING, ERROR, CRITICAL)
|
||||||
* %(levelname)s Text logging level for the message ("DEBUG", "INFO",
|
* %(levelname)s Text logging level for the message ("DEBUG", "INFO",
|
||||||
* "WARNING", "ERROR", "CRITICAL")
|
* "WARNING", "ERROR", "CRITICAL")
|
||||||
* %(created)f Time when the LogRecord was created (Date.now()
|
* %(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
|
||||||
* return value)
|
* return value)
|
||||||
* %(asctime)s Textual time when the LogRecord was created
|
* %(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
|
* %(message)s The result of record.getMessage(), computed just as
|
||||||
* the record is emitted
|
* the record is emitted
|
||||||
*/
|
*/
|
||||||
export class Formatter {
|
export class Formatter {
|
||||||
public static defaultTimeFormat = 'YYYY-MM-DDTHH:mm:ss';
|
public static defaultTimeFormat = '%Y-%M';
|
||||||
public static defaultMsecFormat = '%s.%03d';
|
public static defaultMsecFormat = '%s,%30d';
|
||||||
|
|
||||||
protected style: PercentFormatterStyle;
|
protected style: any;
|
||||||
protected fmt: string;
|
protected fmt: string;
|
||||||
protected datefmt: string|undefined;
|
protected datefmt: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
constructor(options?: FormatterOptions) {
|
||||||
options = options ?? {};
|
options = options ?? {};
|
||||||
const style = options.style ?? '%';
|
var style = options.style ?? '%';
|
||||||
const validate = options.validate ?? true;
|
var 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(', ')}`)
|
throw new ValueError(`style must be one of: ${Object.keys(STYLES).join(', ')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,60 +157,45 @@ export class Formatter {
|
||||||
this.datefmt = options.datefmt;
|
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.
|
* Return the creation time of the specified LogRecord as formatted text.
|
||||||
*
|
*
|
||||||
* If datefmt is specified, it is used as a strftime-style format string.
|
* This method should be called from format() by a formatter which
|
||||||
* Supported tokens: %Y, %m, %d, %H, %M, %S.
|
* wants to make use of a formatted time. This method can be overridden
|
||||||
* Otherwise, an ISO8601-like format is used.
|
* 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.
|
||||||
*/
|
*/
|
||||||
formatTime(record: LogRecord, datefmt?: string): string {
|
formatTime(record: LogRecord, datefmt?: any): string {
|
||||||
const dt = new Date(record.created);
|
|
||||||
|
|
||||||
|
//TODO: record.created
|
||||||
if (datefmt) {
|
if (datefmt) {
|
||||||
return datefmt
|
//TODO: time.strftime
|
||||||
.replace('%Y', String(dt.getFullYear()))
|
}
|
||||||
.replace('%m', String(dt.getMonth() + 1).padStart(2, '0'))
|
else {
|
||||||
.replace('%d', String(dt.getDate()).padStart(2, '0'))
|
//TODO: time.strftime
|
||||||
.replace('%H', String(dt.getHours()).padStart(2, '0'))
|
|
||||||
.replace('%M', String(dt.getMinutes()).padStart(2, '0'))
|
|
||||||
.replace('%S', String(dt.getSeconds()).padStart(2, '0'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const iso = dt.toISOString();
|
return 'some time';
|
||||||
return iso.replace('T', ' ').replace('Z', '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format and return the specified exception information as a string.
|
* Format and return the specified exception information as a string.
|
||||||
|
|
||||||
|
* This default implementation just uses
|
||||||
|
* traceback.print_exception()
|
||||||
*/
|
*/
|
||||||
formatError(ei: MyError): string {
|
formatError(ei: MyError): string {
|
||||||
if (ei.stack) { return ei.stack }
|
//TODO
|
||||||
return String(ei);
|
return 'some error';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
240
src/handler.ts
240
src/handler.ts
|
|
@ -1,9 +1,17 @@
|
||||||
import { LogLevel, checkLevel, NOTSET, WARNING, ERROR } from './log-level';
|
import * as stream from 'stream';
|
||||||
|
|
||||||
|
import { LogLevel, checkLevel, NOTSET } from './log-level';
|
||||||
import { LogRecord } from './log-record';
|
import { LogRecord } from './log-record';
|
||||||
import { Formatter, DEFAULT_FORMATTER } from './formatter';
|
import { Formatter, DEFAULT_FORMATTER } from './formatter';
|
||||||
import { Filterer } from './filter';
|
import { Filterer } from './filter';
|
||||||
import { NotImplementedError } from './helper/error';
|
import { NotImplementedError } from './helper/error';
|
||||||
import { Writable, DEFAULT_STREAM, StderrWritable } from './helper/stream';
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
const stream = require('stream');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const stream = require('./helper/stream');
|
||||||
|
}
|
||||||
|
|
||||||
//---------------------------------------------------------------------------
|
//---------------------------------------------------------------------------
|
||||||
// Handler classes and functions
|
// Handler classes and functions
|
||||||
|
|
@ -70,7 +78,7 @@ export class Handler extends Filterer {
|
||||||
}
|
}
|
||||||
|
|
||||||
get level(): number { return this._level }
|
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 }
|
get scope(): string|null { return this._scope }
|
||||||
set scope(scope: string) { this._scope = scope }
|
set scope(scope: string) { this._scope = scope }
|
||||||
|
|
@ -82,9 +90,11 @@ export class Handler extends Filterer {
|
||||||
* If a formatter is set, use it. Otherwise, use the default formatter for
|
* If a formatter is set, use it. Otherwise, use the default formatter for
|
||||||
* the module.
|
* the module.
|
||||||
*/
|
*/
|
||||||
format(record: LogRecord): string {
|
format(record: LogRecord) {
|
||||||
const fmt = this.formatter ?? DEFAULT_FORMATTER;
|
var fmt: Formatter|null = null;
|
||||||
return fmt.format(record);
|
|
||||||
|
if (this.formatter) { fmt = this.formatter }
|
||||||
|
else { fmt = DEFAULT_FORMATTER }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -107,13 +117,14 @@ export class Handler extends Filterer {
|
||||||
* I/O thread lock.
|
* I/O thread lock.
|
||||||
*/
|
*/
|
||||||
handle(record: LogRecord) {
|
handle(record: LogRecord) {
|
||||||
const rv = this.filter(record);
|
var rv = this.filter(record);
|
||||||
if (!rv) { return }
|
|
||||||
let filtered = record;
|
|
||||||
if ((rv as any) instanceof LogRecord) {
|
if ((rv as any) instanceof LogRecord) {
|
||||||
filtered = rv as unknown as LogRecord
|
record = rv as unknown as LogRecord
|
||||||
|
}
|
||||||
|
if (rv) {
|
||||||
|
//locking here
|
||||||
|
this.emit(record)
|
||||||
}
|
}
|
||||||
this.emit(filtered)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -135,73 +146,22 @@ export class Handler extends Filterer {
|
||||||
* Handle errors which occur during an emit() call.
|
* Handle errors which occur during an emit() call.
|
||||||
*
|
*
|
||||||
* This method should be called from handlers when an exception is
|
* This method should be called from handlers when an exception is
|
||||||
* encountered during an emit() call.
|
* 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.
|
||||||
*/
|
*/
|
||||||
handleError(record: LogRecord) {
|
handleError(record: LogRecord) {
|
||||||
try {
|
throw new NotImplementedError(
|
||||||
console.error('--- Logging error ---');
|
'still need to find portable way for stacktracing...'
|
||||||
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 }
|
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 {
|
export interface FileHandlerOptions {
|
||||||
filename: string
|
filename: string
|
||||||
filemode?: string
|
filemode?: string
|
||||||
|
|
@ -209,121 +169,20 @@ export interface FileHandlerOptions {
|
||||||
errors?: string
|
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 {
|
export class FileHandler extends StreamHandler {
|
||||||
constructor(options: FileHandlerOptions) {
|
constructor(options: FileHandlerOptions) {
|
||||||
super();
|
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,16 +191,9 @@ export class LocalStorageHandler extends Handler {
|
||||||
* whatever sys.stderr is currently set to rather than the value of
|
* whatever sys.stderr is currently set to rather than the value of
|
||||||
* sys.stderr at handler construction time.
|
* sys.stderr at handler construction time.
|
||||||
*/
|
*/
|
||||||
export class StderrHandler extends Handler {
|
export class StderrHandler extends Handler {
|
||||||
|
/**
|
||||||
|
* Initialize the handler.
|
||||||
|
*/
|
||||||
constructor(level: LogLevel) { super(level) }
|
constructor(level: LogLevel) { super(level) }
|
||||||
|
|
||||||
emit(record: LogRecord) {
|
|
||||||
try {
|
|
||||||
const msg = this.format(record);
|
|
||||||
console.error(msg);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
this.handleError(record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,2 @@
|
||||||
/**
|
|
||||||
* 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';
|
export * as handler from './handler';
|
||||||
// screw community conventions, whoever came up with the idea of aliasing
|
// 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
|
// imports in pascal case, or camel case doesn't seem to care about naming
|
||||||
// collisions. I'm sticking to kebab case as this avoids naming collisions.
|
// collisions. I'm sticking to snake case as this avoids naming collisions.
|
||||||
export * as log_level from './log-level';
|
export * as log_level from './log-level';
|
||||||
export * as log_record from './log-record';
|
export * as log_record from './log-record';
|
||||||
export * as logger from './logger';
|
export * as logger from './logger';
|
||||||
|
|
|
||||||
|
|
@ -48,32 +48,13 @@ export class LogRecord {
|
||||||
public readonly levelno: LogLevel;
|
public readonly levelno: LogLevel;
|
||||||
public readonly levelname: string|LogLevel;
|
public readonly levelname: string|LogLevel;
|
||||||
public readonly scope: string;
|
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 readonly created: MillisecondsSinceUnixEpoch = Date.now();
|
||||||
public asctime: string = '';
|
|
||||||
|
|
||||||
constructor(scope: string, options: LogRecordOptions) {
|
constructor(scope: string, options: LogRecordOptions) {
|
||||||
this.levelno = options.level;
|
this.levelno = options.level;
|
||||||
this.levelname = getLevelName(options.level);
|
this.levelname = getLevelName(options.level);
|
||||||
this.scope = scope;
|
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,11 +1,8 @@
|
||||||
import {
|
import {
|
||||||
LogLevel,
|
LogLevel,
|
||||||
DEBUG,
|
DEBUG,
|
||||||
INFO,
|
|
||||||
WARNING,
|
|
||||||
ERROR,
|
|
||||||
CRITICAL,
|
|
||||||
NOTSET,
|
NOTSET,
|
||||||
|
WARNING,
|
||||||
checkLevel,
|
checkLevel,
|
||||||
} from './log-level';
|
} from './log-level';
|
||||||
import {
|
import {
|
||||||
|
|
@ -111,13 +108,10 @@ export class Logger extends Filterer {
|
||||||
|
|
||||||
public set level(level: LogLevel) { this._level = checkLevel(level) }
|
public set level(level: LogLevel) { this._level = checkLevel(level) }
|
||||||
|
|
||||||
public get manager(): Manager|null { return this._manager }
|
|
||||||
|
|
||||||
public set manager(manager: Manager) {
|
public set manager(manager: Manager) {
|
||||||
if (this._manager) {
|
if (this.manager) {
|
||||||
throw new ValueError('logger can only be assigned to manager once');
|
throw new ValueError('logger can only be assigned to manager once');
|
||||||
}
|
}
|
||||||
this._manager = manager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLevel(level: LogLevel) {
|
public setLevel(level: LogLevel) {
|
||||||
|
|
@ -149,17 +143,13 @@ export class Logger extends Filterer {
|
||||||
public isEnabledFor(level: LogLevel): boolean {
|
public isEnabledFor(level: LogLevel): boolean {
|
||||||
if (this.disabled) { return false }
|
if (this.disabled) { return false }
|
||||||
|
|
||||||
if (level in this.cache) {
|
if (this.cache[level] === undefined && this.manager && this.manager.disable < level) {
|
||||||
return this.cache[level];
|
return this.cache[level] = (
|
||||||
|
level >= this.getEffectiveLevel()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._manager && this._manager.disable >= level) {
|
return this.cache[level] = false;
|
||||||
this.cache[level] = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cache[level] = level >= this.getEffectiveLevel();
|
|
||||||
return this.cache[level];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -176,34 +166,6 @@ export class Logger extends Filterer {
|
||||||
if (this.isEnabledFor(DEBUG)) { this._log(DEBUG, msg, options) }
|
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
|
* A factory method which can be overriden in subclasses to create
|
||||||
* specialized LogRecords.
|
* specialized LogRecords.
|
||||||
|
|
@ -229,9 +191,9 @@ export class Logger extends Filterer {
|
||||||
|
|
||||||
var [k, v] = item;
|
var [k, v] = item;
|
||||||
|
|
||||||
if (['message', 'asctime'].includes(k) ||
|
if (['message', 'asctime'].includes(k as string) ||
|
||||||
Object.keys(rv).includes(k)) {
|
(rv as {[key: string]: any}).keys().includes(k as string)) {
|
||||||
throw new KeyError(`attempt to overwrite ${k} in LogRecord`)
|
throw new KeyError('attempt to overwrite ${k} in LogRecord')
|
||||||
}
|
}
|
||||||
|
|
||||||
(rv as any)[k] = options.extra![k as string] as any
|
(rv as any)[k] = options.extra![k as string] as any
|
||||||
|
|
@ -264,8 +226,7 @@ export class Logger extends Filterer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = this.makeRecord(this.scope, level, msg, options);
|
var record = this.makeRecord(this.scope, level, msg, options)
|
||||||
this.handle(this.scope, record);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ var loggerClass = Logger;
|
||||||
* Placeholder instance
|
* Placeholder instance
|
||||||
*/
|
*/
|
||||||
class Placeholder {
|
class Placeholder {
|
||||||
public loggers: Logger[] = [];
|
protected loggers: Logger[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* initialize with the specified logger being a child of this placeholder.
|
* initialize with the specified logger being a child of this placeholder.
|
||||||
|
|
@ -71,76 +71,20 @@ export class Manager {
|
||||||
* up the parent/child references which pointed to the placeholder to now
|
* up the parent/child references which pointed to the placeholder to now
|
||||||
* point to the logger.
|
* point to the logger.
|
||||||
*/
|
*/
|
||||||
getLogger(scope: string): Logger {
|
getLogger(scope: string) {
|
||||||
let rv: Logger;
|
var rv: null|Logger = null;
|
||||||
|
|
||||||
if (scope in this.loggers) {
|
if (typeof scope != 'string') {
|
||||||
const existing = this.loggers[scope];
|
|
||||||
|
|
||||||
if (existing instanceof Placeholder) {
|
rv = this.loggers[scope];
|
||||||
|
|
||||||
|
if (rv instanceof Placeholder) {
|
||||||
|
var ph = rv;
|
||||||
|
rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET);
|
||||||
|
}
|
||||||
|
else {
|
||||||
rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET);
|
rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET);
|
||||||
rv.manager = this;
|
|
||||||
this.loggers[scope] = rv;
|
this.loggers[scope] = rv;
|
||||||
this._fixupChildren(existing, rv);
|
|
||||||
this._fixupParents(rv);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -171,9 +115,7 @@ export class Manager {
|
||||||
*/
|
*/
|
||||||
public clear() {
|
public clear() {
|
||||||
Object.values(this.loggers).forEach((logger) => {
|
Object.values(this.loggers).forEach((logger) => {
|
||||||
if (!(logger instanceof Placeholder)) {
|
logger.clear()
|
||||||
logger.clear();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import {expect, jest, test} from '@jest/globals';
|
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', () => {
|
describe('Logger', () => {
|
||||||
it('can be instantiated', () => {
|
it('can be instantiated', () => {
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
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,209 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
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
1
vendor/tiara-gitflow-spec
vendored
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit ee93479edce1da28f4abf68a362427f8d3134f80
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue