@mmutils/logger 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +901 -2
- package/dist/cjs/index.cjs +72 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/levels.cjs +82 -0
- package/dist/cjs/levels.cjs.map +1 -0
- package/dist/cjs/logger.cjs +576 -0
- package/dist/cjs/logger.cjs.map +1 -0
- package/dist/cjs/outputs.cjs +24 -0
- package/dist/cjs/outputs.cjs.map +1 -0
- package/dist/cjs/plugins/columns.cjs +782 -0
- package/dist/cjs/plugins/columns.cjs.map +1 -0
- package/dist/cjs/plugins/graph.cjs +578 -0
- package/dist/cjs/plugins/graph.cjs.map +1 -0
- package/dist/cjs/plugins/json.cjs +644 -0
- package/dist/cjs/plugins/json.cjs.map +1 -0
- package/dist/cjs/plugins/message.cjs +108 -0
- package/dist/cjs/plugins/message.cjs.map +1 -0
- package/dist/cjs/plugins/shared.cjs +194 -0
- package/dist/cjs/plugins/shared.cjs.map +1 -0
- package/dist/cjs/time.cjs +234 -0
- package/dist/cjs/time.cjs.map +1 -0
- package/dist/cjs/utils/text.cjs +646 -0
- package/dist/cjs/utils/text.cjs.map +1 -0
- package/dist/esm/index.js +11 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/levels.js +72 -0
- package/dist/esm/levels.js.map +1 -0
- package/dist/esm/logger.js +572 -0
- package/dist/esm/logger.js.map +1 -0
- package/dist/esm/outputs.js +21 -0
- package/dist/esm/outputs.js.map +1 -0
- package/dist/esm/plugins/columns.js +778 -0
- package/dist/esm/plugins/columns.js.map +1 -0
- package/dist/esm/plugins/graph.js +572 -0
- package/dist/esm/plugins/graph.js.map +1 -0
- package/dist/esm/plugins/json.js +641 -0
- package/dist/esm/plugins/json.js.map +1 -0
- package/dist/esm/plugins/message.js +105 -0
- package/dist/esm/plugins/message.js.map +1 -0
- package/dist/esm/plugins/shared.js +187 -0
- package/dist/esm/plugins/shared.js.map +1 -0
- package/dist/esm/time.js +231 -0
- package/dist/esm/time.js.map +1 -0
- package/dist/esm/utils/text.js +621 -0
- package/dist/esm/utils/text.js.map +1 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/levels.d.ts +50 -0
- package/dist/types/logger.d.ts +245 -0
- package/dist/types/outputs.d.ts +12 -0
- package/dist/types/plugins/columns.d.ts +151 -0
- package/dist/types/plugins/graph.d.ts +243 -0
- package/dist/types/plugins/json.d.ts +169 -0
- package/dist/types/plugins/message.d.ts +181 -0
- package/dist/types/plugins/shared.d.ts +74 -0
- package/dist/types/time.d.ts +48 -0
- package/dist/types/types.d.ts +176 -0
- package/dist/types/utils/text.d.ts +231 -0
- package/package.json +85 -4
package/README.md
CHANGED
|
@@ -1,3 +1,902 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mmutils/logger
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small, plugin-extensible TypeScript logger. Every log is a plain object that
|
|
4
|
+
flows through a pipeline of transformers and template maps to an output.
|
|
5
|
+
Plugins add behaviour, styles and fully typed methods, so the core stays tiny
|
|
6
|
+
and you only pay for what you use.
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
<details>
|
|
11
|
+
<summary><sub>The output above as plain text</sub></summary>
|
|
12
|
+
|
|
13
|
+
<!-- output:quick-start -->
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
2026/03/16 15:35:00 [i] info Server started
|
|
17
|
+
2026/03/16 15:35:00 /!\ warning
|
|
18
|
+
user: "alice"
|
|
19
|
+
retries: 3
|
|
20
|
+
2026/03/16 15:35:00 [!] error
|
|
21
|
+
Error: Connection lost
|
|
22
|
+
at main (file:///app/server.js:12:16)
|
|
23
|
+
2026/03/16 15:35:00 [i] info
|
|
24
|
+
CPU 21.00 %
|
|
25
|
+
│ █████ │
|
|
26
|
+
│ ▁▁▁▁▁█████▁▁▁▁▁ │
|
|
27
|
+
│▁▁▁▁▁▃▃▃▃▃███████████████▃▃▃▃▃│
|
|
28
|
+
average: 7.33 % 0.00 %
|
|
29
|
+
min: 1.00 %
|
|
30
|
+
max: 21.00 %
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
<!-- /output -->
|
|
34
|
+
|
|
35
|
+
<sub><i>Bars and frames may not line up in this text if your font lacks the block characters; the image shows the real alignment.</i></sub>
|
|
36
|
+
|
|
37
|
+
</details>
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Styled messages**: a colored badge per level; text, objects, arrays and
|
|
42
|
+
errors laid out to read at a glance, in the terminal's 16 basic colors, so
|
|
43
|
+
they follow its theme.
|
|
44
|
+
- **Bar graphs in the terminal**: one method per level (`infoGraph`, …),
|
|
45
|
+
average, min and max, time axis labels (`0ms … 2s 25ms`), bars split at a
|
|
46
|
+
baseline for negative values, and `aggregate: 'max'` to keep spikes
|
|
47
|
+
visible.
|
|
48
|
+
- **Columns**: messages and graphs side by side, call for call, with
|
|
49
|
+
wrapping per kind and a live mode that redraws cells in place (progress,
|
|
50
|
+
status boards).
|
|
51
|
+
- **JSON lines** (NDJSON) for log files and collectors: errors keep their
|
|
52
|
+
message, stack and properties, times are ISO or epoch, with nanoseconds
|
|
53
|
+
from the precise clock.
|
|
54
|
+
- **Timestamps** from dayjs-style patterns (`HH:mm:ss.SSS`), compiled once,
|
|
55
|
+
without `Intl`, and a precise clock with nanoseconds for benchmarks and
|
|
56
|
+
tracing.
|
|
57
|
+
- **Plugin based**: each of the above is a plugin, so you only add what you
|
|
58
|
+
use, or write your own with typed methods and styles. No global
|
|
59
|
+
configuration: every logger has its own.
|
|
60
|
+
- **Typed, and checked at runtime**: plugin methods and re-declared
|
|
61
|
+
signatures show up in autocomplete, with their docs; levels that come
|
|
62
|
+
from configuration as strings are checked too (`'warn'` throws instead of
|
|
63
|
+
turning every log off).
|
|
64
|
+
- **Fast by design**: styles are baked to escape-code strings once, the
|
|
65
|
+
timestamp text is cached per second, filtered logs return in a few
|
|
66
|
+
nanoseconds. See the [benchmarks](bench/README.md).
|
|
67
|
+
- **Safe with any data**: logged text can't send terminal escape sequences,
|
|
68
|
+
output size is limited, and logging never throws because of the data.
|
|
69
|
+
- **ESM and CommonJS**, with type declarations. Two tiny runtime dependencies.
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npm install @mmutils/logger
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Requires Node.js 20 or newer.
|
|
78
|
+
|
|
79
|
+
## Quick start
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { Logger, messagePlugin, graphPlugin } from '@mmutils/logger';
|
|
83
|
+
|
|
84
|
+
const logger = new Logger({
|
|
85
|
+
level: 'debug',
|
|
86
|
+
plugins: [messagePlugin(), graphPlugin()],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
logger.info('Server started');
|
|
90
|
+
logger.warning({ user: 'alice', retries: 3 });
|
|
91
|
+
logger.error(new Error('Connection lost'));
|
|
92
|
+
logger.infoGraph([1, 3, 8, 21, 8, 3], { title: 'CPU', unit: '%' });
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This prints the sample at the top. Plugins can also be chained, with the
|
|
96
|
+
same typing:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const logger = new Logger().use(messagePlugin()).use(graphPlugin());
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Without plugins
|
|
103
|
+
|
|
104
|
+
The core logger outputs the log entry object itself:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
new Logger().info([1, 2, 3, 4]);
|
|
108
|
+
// { id: 1, timestamp: '2026/03/16 15:35:00', type: 'info', kind: 'default', data: [1, 2, 3, 4] }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Every `log(type, data, kind)` call:
|
|
112
|
+
|
|
113
|
+
1. checks the type against `level`; filtered logs return before anything is
|
|
114
|
+
created;
|
|
115
|
+
2. creates the entry `{ id, timestamp, type, kind, data }` (no `timestamp`
|
|
116
|
+
with `timestamp: false`);
|
|
117
|
+
3. runs every function in `transformers`. The first is
|
|
118
|
+
`templateTransformer`, which passes each entry property through the
|
|
119
|
+
template map of the entry's kind (`templateMaps[kind]`);
|
|
120
|
+
4. hands the result to the output of the kind, or to the default `output`.
|
|
121
|
+
|
|
122
|
+
Plugins hook into steps 3 and 4, and so can you:
|
|
123
|
+
|
|
124
|
+
| Member | Description |
|
|
125
|
+
| ------------------------------- | -------------------------------------------------------------- |
|
|
126
|
+
| `use(plugin)` | Registers a plugin; returns the logger typed with its methods. |
|
|
127
|
+
| `hasPlugin(name)` | Whether a plugin with that name is used. |
|
|
128
|
+
| `addTransformer(fn, position?)` | Inserts a pipeline step, at the end by default. |
|
|
129
|
+
| `removeTransformer(fn)` | Removes a step; `true` if it was found. |
|
|
130
|
+
| `replaceTransformer(old, fn)` | Replaces a step in place; `true` if it was found. |
|
|
131
|
+
| `addTemplateMap(kind, map)` | Adds template functions for a kind, merged with existing ones. |
|
|
132
|
+
| `setOutput(kind, output)` | Sets the output of a kind. |
|
|
133
|
+
| `getOutput(kind)` | The output of a kind, or the default output. |
|
|
134
|
+
| `addStyles(sheet)` | Adds a style sheet and rebakes the styles, see Styles. |
|
|
135
|
+
| `getStyles(type)` | The baked styles of a log type. |
|
|
136
|
+
| `level`, `defaultKind` | Can be changed at any time. |
|
|
137
|
+
| `transformers`, `templateMaps` | The pipeline, readable and mutable. |
|
|
138
|
+
|
|
139
|
+
### Options
|
|
140
|
+
|
|
141
|
+
| Option | Default | Description |
|
|
142
|
+
| ------------- | ------------------------------- | -------------------------------------------------------------------------------------- |
|
|
143
|
+
| `level` | `'info'` | Minimum level: `silent` `fatal` `error` `warning` `info` `debug` `trace` `verbose`. |
|
|
144
|
+
| `plugins` | `[]` | Plugins to use, in order. Their methods are typed. |
|
|
145
|
+
| `timestamp` | `'YYYY/MM/DD HH:mm:ss'` (local) | A pattern, `{ format?, utc? }`, `(time, timeNs) => string` or `false`. See Timestamps. |
|
|
146
|
+
| `clock` | `'wall'` | `'wall'` (`Date.now()`), `'precise'` (nanoseconds) or `() => epochMs`. See Timestamps. |
|
|
147
|
+
| `useStyles` | `true` | `false` bakes every style to an empty string. |
|
|
148
|
+
| `output` | `console.log` | Output for kinds without a plugin output. |
|
|
149
|
+
| `defaultKind` | `'default'` | Kind of logs without an explicit kind. |
|
|
150
|
+
|
|
151
|
+
Log methods: `fatal`, `error`, `warning`, `info`, `debug`, `trace`, `verbose`,
|
|
152
|
+
and `log(type, data, kind?)`. `logger.level` can be changed at any time.
|
|
153
|
+
Levels and types are checked at runtime too, since levels often come from
|
|
154
|
+
configuration or environment variables as plain strings: an unknown level
|
|
155
|
+
(e.g. `'warn'`) throws a `RangeError` instead of silently turning off all
|
|
156
|
+
logs, and an unknown type (or `'silent'`, a level only) throws a
|
|
157
|
+
`TypeError` from `log`.
|
|
158
|
+
|
|
159
|
+
### Timestamps
|
|
160
|
+
|
|
161
|
+
The timestamp is when `log` was called. It's a dayjs-style pattern, compiled
|
|
162
|
+
once when the logger is created:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
new Logger(); // 2026/03/16 15:35:00 (DEFAULT_TIMESTAMP_FORMAT, local time)
|
|
166
|
+
new Logger({ timestamp: 'HH:mm:ss.SSS' }); // 15:35:00.042
|
|
167
|
+
new Logger({ timestamp: { format: 'YYYY-MM-DD[T]HH:mm:ss[Z]', utc: true } });
|
|
168
|
+
new Logger({ timestamp: 'h:mm:ss A' }); // 3:35:00 PM
|
|
169
|
+
new Logger({ clock: 'precise', timestamp: 'ss.SSSSSSSSS EEE' }); // 00.042137508 +12.345678ms
|
|
170
|
+
new Logger({ timestamp: (time, timeNs) => myFormat(time) }); // any formatter
|
|
171
|
+
new Logger({ timestamp: false }); // no timestamp, the clock isn't read
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
| Token | Output |
|
|
175
|
+
| -------------------------- | -------------------------------------------------------- |
|
|
176
|
+
| `YYYY` `YY` | `2026` `26` |
|
|
177
|
+
| `MMMM` `MMM` `MM` `M` | `March` `Mar` `03` `3` |
|
|
178
|
+
| `DD` `D`, `dddd` `ddd` | `16` `16`, `Monday` `Mon` |
|
|
179
|
+
| `HH` `H` | `15` `15` (24 hours) |
|
|
180
|
+
| `hh` `h` `A` `a` | `03` `3` `PM` `pm` (12 hours) |
|
|
181
|
+
| `mm` `m`, `ss` `s` | minutes, seconds |
|
|
182
|
+
| `SSS` `SSSSSS` `SSSSSSSSS` | milliseconds, microseconds, nanoseconds |
|
|
183
|
+
| `Z` `ZZ` | `+02:00` `+0200` |
|
|
184
|
+
| `E` | elapsed since the logger was created: `+1s 32ms 400µs` |
|
|
185
|
+
| `EEE` | elapsed in milliseconds: `+1032.400123ms` |
|
|
186
|
+
| `[text]` | `text` as is (other letters may be tokens: use brackets) |
|
|
187
|
+
|
|
188
|
+
**Clocks.** `'wall'` (default) is `Date.now()`: the system's wall time, in
|
|
189
|
+
milliseconds. `'precise'` is `process.hrtime` anchored to `Date.now()` when
|
|
190
|
+
the logger is created: exact nanoseconds and monotonic, for benchmarks and
|
|
191
|
+
tracing; like any monotonic clock it can drift from wall time on
|
|
192
|
+
long-running processes (NTP corrections, sleep). A function returning epoch
|
|
193
|
+
milliseconds replaces the clock, e.g. a fixed time in tests. Durations you
|
|
194
|
+
measure yourself belong in the data (`{ took: 300 }`), not in the timestamp.
|
|
195
|
+
|
|
196
|
+
**Performance.** Text that changes at most once per second is built with
|
|
197
|
+
`Date` getters (no `Intl`) and cached; a log pays the clock read (about
|
|
198
|
+
70 ns), a comparison, and its fraction tokens. `E` costs about 1 µs per log
|
|
199
|
+
(`formatHighResTime`), `EEE` about 0.25 µs. Change the default pattern in one
|
|
200
|
+
place: `DEFAULT_TIMESTAMP_FORMAT`.
|
|
201
|
+
|
|
202
|
+
## Message plugin
|
|
203
|
+
|
|
204
|
+
`messagePlugin()` renders logs as styled text and becomes the default kind, so
|
|
205
|
+
every `logger.info(...)` is a message. Text stays on the header line; objects,
|
|
206
|
+
arrays and errors start on the next line, one property (or stack frame) per
|
|
207
|
+
line.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
messagePlugin({
|
|
211
|
+
showId: false, // show #id before the date
|
|
212
|
+
symbol: DEFAULT_SYMBOLS, // per type in front of the badge, see Symbols
|
|
213
|
+
kind: 'message', // kind rendered by the plugin
|
|
214
|
+
setAsDefault: true, // make `kind` the logger's default kind
|
|
215
|
+
sanitize: 'safe', // control characters of logged text, see Sanitize
|
|
216
|
+
limits: { items: 100, stringLength: 10_000 }, // output size, see Limits
|
|
217
|
+
styles: {}, // style overrides, see Styles
|
|
218
|
+
write: console.log, // string destination
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Limits
|
|
223
|
+
|
|
224
|
+
Like `util.inspect`, the message plugin caps what a single log prints, so
|
|
225
|
+
logging a large buffer, array or string can't block the process or flood
|
|
226
|
+
the output:
|
|
227
|
+
|
|
228
|
+
- `items` (default `100`): array, buffer and typed array elements, object
|
|
229
|
+
keys, map and set entries and stack frames. Buffers and typed arrays print
|
|
230
|
+
on one line: `<Buffer 68 65 6c … 1048573 more bytes>`.
|
|
231
|
+
- `stringLength` (default `10_000`): characters of a message, key, error
|
|
232
|
+
message or value. Text is cut before it is sanitized; string values are
|
|
233
|
+
cut before JSON encoding.
|
|
234
|
+
|
|
235
|
+
What was left out is noted, e.g. `… 999900 more items`. Nested objects are
|
|
236
|
+
still serialized by the native `JSON.stringify` before their text is cut.
|
|
237
|
+
`Infinity` turns a limit off:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
messagePlugin({ limits: { items: Infinity, stringLength: Infinity } });
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The graph plugin takes the same `items` and `stringLength` in its `limits`,
|
|
244
|
+
for data that is not a graph.
|
|
245
|
+
|
|
246
|
+
### Sanitize
|
|
247
|
+
|
|
248
|
+
By default, logged text (strings, object keys and values, errors, graph
|
|
249
|
+
titles and labels) can't send terminal escape sequences (cursor moves, screen
|
|
250
|
+
clears, window titles, hyperlinks, clipboard writes) or start a line that
|
|
251
|
+
looks like a log header. `sanitize` is available on both bundled plugins:
|
|
252
|
+
|
|
253
|
+
| `sanitize` | Terminal sequences | Colors (`\x1B[...m`) | Newlines |
|
|
254
|
+
| ------------------ | ------------------ | -------------------- | --------------------------- |
|
|
255
|
+
| `'safe'` (default) | `\xHH` | kept, then a reset | continuation lines indented |
|
|
256
|
+
| `'strict'` | `\xHH` | `\xHH` | `\x0A`, one line per log |
|
|
257
|
+
| `'none'` | kept | kept | kept, only for trusted text |
|
|
258
|
+
| custom rules | `\xHH` | `colors: 'keep'` | `newlines: 'indent'` |
|
|
259
|
+
| | | or `'escape'` | or `'keep'` or `'escape'` |
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
messagePlugin({ sanitize: { colors: 'escape', newlines: 'keep' } });
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Single line text (object keys, stack frames, graph titles and labels) always
|
|
266
|
+
has its newlines escaped. Clean text costs one regex scan and no allocation;
|
|
267
|
+
`'none'` costs nothing.
|
|
268
|
+
|
|
269
|
+
## Graph plugin
|
|
270
|
+
|
|
271
|
+
`graphPlugin()` draws bar graphs and adds one method per level: `fatalGraph`,
|
|
272
|
+
`errorGraph`, `warningGraph`, `infoGraph`, `debugGraph`, `traceGraph`,
|
|
273
|
+
`verboseGraph`. Settings can be set on the plugin and overridden per graph.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
logger.infoGraph(values, {
|
|
277
|
+
cols: 30, // bars; the frame adds 2, so 32 terminal columns
|
|
278
|
+
// width: 32, // or the total width, frame included (not with cols)
|
|
279
|
+
rows: 3, // bar rows
|
|
280
|
+
title: 'CPU',
|
|
281
|
+
unit: '%', // appended to values
|
|
282
|
+
precision: 2,
|
|
283
|
+
max: 100, // top, default: from the values, see below
|
|
284
|
+
min: 0, // bottom, default: from the values
|
|
285
|
+
baseline: 0, // where bars start: a value, 'min' or 'max', see below
|
|
286
|
+
aggregate: 'average', // 'average' | 'max' | 'min', see below
|
|
287
|
+
showAverage: true,
|
|
288
|
+
showMin: true,
|
|
289
|
+
showMax: true,
|
|
290
|
+
axis: { start: 0, end: 1032.4, unit: 'ms' }, // optional axis row
|
|
291
|
+
});
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+

|
|
295
|
+
|
|
296
|
+
<details>
|
|
297
|
+
<summary><sub>The output above as plain text</sub></summary>
|
|
298
|
+
|
|
299
|
+
<!-- output:graph -->
|
|
300
|
+
|
|
301
|
+
```text
|
|
302
|
+
2026/03/16 15:35:00 [i] info
|
|
303
|
+
Latency 89.17 ms
|
|
304
|
+
│ █ │
|
|
305
|
+
│ ▁▁ ▁▁█ │
|
|
306
|
+
│▆█████▇▅▄▃▂▃▄▅▆█████▇▆▄▃▂▂▃▅▆▇│
|
|
307
|
+
0ms 1s 32ms
|
|
308
|
+
average: 20.72 ms 0.00 ms
|
|
309
|
+
min: 8.00 ms
|
|
310
|
+
max: 89.17 ms
|
|
311
|
+
2026/03/16 15:35:00 /!\ warning
|
|
312
|
+
Queue 9.00
|
|
313
|
+
│ ▁▁▃▃▄▆▆██▆▆▄▃▃▁▁ │
|
|
314
|
+
│▂▂▄▄▅▇▇████████████████▇▇▅▄▄▂▂│
|
|
315
|
+
Mon Fri
|
|
316
|
+
average: 4.76 0.00
|
|
317
|
+
min: 1.00
|
|
318
|
+
max: 9.00
|
|
319
|
+
2026/03/16 15:35:00 [i] info
|
|
320
|
+
Temperature 25.1 °C
|
|
321
|
+
│ ▁▂▁▁ │
|
|
322
|
+
│ ▄▅▇██████▆▆▂▂ │
|
|
323
|
+
│ ▀▀█████████████│
|
|
324
|
+
│ ▔▀▀█████▀▀▔▔│
|
|
325
|
+
Mar 2 Mar 15
|
|
326
|
+
average: 18.4 °C 11.5 °C
|
|
327
|
+
baseline: 20.0 °C
|
|
328
|
+
min: 11.5 °C
|
|
329
|
+
max: 25.1 °C
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
<!-- /output -->
|
|
333
|
+
|
|
334
|
+
<sub><i>Bars and frames may not line up in this text if your font lacks the block characters; the image shows the real alignment.</i></sub>
|
|
335
|
+
|
|
336
|
+
</details>
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
The plugin takes the same settings, as defaults for all its graphs, plus:
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
graphPlugin({
|
|
344
|
+
// cols, width, rows, title, unit, … as above
|
|
345
|
+
limits: { cols: 500, rows: 50, precision: 20 }, // see below
|
|
346
|
+
kind: 'graph', // kind rendered by the plugin
|
|
347
|
+
showId: false, // show #id before the date
|
|
348
|
+
symbol: DEFAULT_SYMBOLS, // per type in front of the badge, see Symbols
|
|
349
|
+
sanitize: 'safe', // see Sanitize
|
|
350
|
+
styles: {}, // style overrides, see Styles
|
|
351
|
+
write: console.log, // string destination
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Defaults: `cols: 30`, `rows: 3`, `precision: 2`, `aggregate: 'average'`,
|
|
356
|
+
`baseline: 0`, empty `title` and `unit`, all statistics shown, no `max`,
|
|
357
|
+
`min` and `axis`.
|
|
358
|
+
Data that isn't an array of numbers, e.g.
|
|
359
|
+
`logger.log('info', 'text', 'graph')`, is printed as text, limited like
|
|
360
|
+
messages (`limits.items`, `limits.stringLength`).
|
|
361
|
+
|
|
362
|
+
**`cols` or `width`.** `cols` is the resolution: the number of bars values are
|
|
363
|
+
scaled to, like `rows` for the height. The `│` frame adds 2 characters, so
|
|
364
|
+
`cols: 64` takes 66 terminal columns. `width` sets the space instead:
|
|
365
|
+
`width: 64` draws 62 bars and every row of the graph is exactly 64 terminal
|
|
366
|
+
columns wide, e.g. to fit a layout column. Wide characters in titles and
|
|
367
|
+
labels (CJK, most emoji) count as two columns, as in `columnsPlugin`. Set one
|
|
368
|
+
of them on the plugin (both throw); per graph, `width` wins.
|
|
369
|
+
|
|
370
|
+
**Statistics** (average, min, max) and the default top and bottom are
|
|
371
|
+
computed from your original values, so they never depend on the graph width.
|
|
372
|
+
The top value is shown on the title row, the bottom value on the average
|
|
373
|
+
row.
|
|
374
|
+
|
|
375
|
+
**`aggregate`** decides how values sharing a column are drawn: `'average'` for
|
|
376
|
+
a smooth trend, `'max'` to keep spikes visible (latency, errors), `'min'` to
|
|
377
|
+
keep dips visible.
|
|
378
|
+
|
|
379
|
+
**`baseline`** is where the bars start: they grow up to values above it and
|
|
380
|
+
down to values below it, in the level's color. It is a value (`0` by
|
|
381
|
+
default), `'min'` (the bottom of the graph) or `'max'` (the top). For values
|
|
382
|
+
from -10 to 10:
|
|
383
|
+
|
|
384
|
+
| `baseline` | Bars |
|
|
385
|
+
| ---------------- | -------------------------------------- |
|
|
386
|
+
| `0` (default) | split at 0: up above it, down below it |
|
|
387
|
+
| `'min'` | only up, from -10 |
|
|
388
|
+
| `'max'` | only down, from 10 |
|
|
389
|
+
| `20` (any value) | only down, from 20: the range holds it |
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
logger.infoGraph(temperatures, {
|
|
393
|
+
title: 'Temperature',
|
|
394
|
+
unit: '°C',
|
|
395
|
+
rows: 4, // a split graph needs 2 or more rows to show both sides
|
|
396
|
+
baseline: 20, // warmer days grow up, colder days down
|
|
397
|
+
});
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
- A value is always in the range, so values from 80 to 85 draw bars from 0
|
|
401
|
+
and keep their proportions. Use it for values with a sign or a target:
|
|
402
|
+
profit and loss, a temperature setpoint, latency against a budget.
|
|
403
|
+
- `'min'` zooms in on values far from zero, e.g. a heap between 480 and
|
|
404
|
+
515 MB; the smallest value sits on the bottom line. `'max'` hangs every
|
|
405
|
+
bar from the top, e.g. how far a battery has drained.
|
|
406
|
+
- A split graph places the baseline in proportion to the range, at the row
|
|
407
|
+
boundary that gives the bars the most room. Both sides share one scale,
|
|
408
|
+
so equal distances from the baseline draw equal bars, and a `baseline`
|
|
409
|
+
row follows the average.
|
|
410
|
+
- A side gets rows only if its longest bar draws at least an eighth of a
|
|
411
|
+
row: -0.01 next to 100 sits on the baseline instead of taking a row.
|
|
412
|
+
- Bars growing down end in the nearest upper block: `▔` (an eighth of a
|
|
413
|
+
row), `▀` (a half) or `█`, so their tips are coarser than those of bars
|
|
414
|
+
growing up. These are the only upper blocks every terminal and font
|
|
415
|
+
draws; the finer ones (`🮂 🮃 🮄 🮅 🮆`) are missing from many.
|
|
416
|
+
- With `aggregate: 'average'`, a column holding values on both sides
|
|
417
|
+
averages toward the baseline; use `'max'` or `'min'` to keep one side's
|
|
418
|
+
extremes.
|
|
419
|
+
|
|
420
|
+
**`max` and `min`** fix the top and the bottom instead of taking them from
|
|
421
|
+
the values, e.g. to draw graphs on one scale. Values beyond them draw full
|
|
422
|
+
bars, or nothing on a side where no bars grow (above a `'max'` baseline,
|
|
423
|
+
below a `'min'` one). A numeric `baseline` must be between them; the
|
|
424
|
+
default `0` moves inside them, so `min: 480` alone starts the bars at 480.
|
|
425
|
+
|
|
426
|
+
**`axis`** adds a row under the bars. Strings are shown as given, numbers as
|
|
427
|
+
plain numbers, or as time when `unit` is set (`'h' 'm' 's' 'ms' 'µs' 'ns'`,
|
|
428
|
+
formatted by [`@mmutils/time`](https://www.npmjs.com/package/@mmutils/time)):
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
{ start: 'Mon', end: 'Fri' } // Mon Fri
|
|
432
|
+
{ start: 500, end: 1_000_500, unit: 'ns' } // 500ns 1ms 500ns
|
|
433
|
+
{ start: 0, end: 1032.4, unit: 'ms', capTo: 1 } // 0ms 1s 32ms
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
A zero label takes the smallest unit of the other label, so both show the
|
|
437
|
+
same precision. `capTo` is that precision: a unit, or the number of units
|
|
438
|
+
below the largest one (default `2`: `1s 32ms 400µs`).
|
|
439
|
+
|
|
440
|
+
**`limits`** caps the size settings, because output grows with
|
|
441
|
+
`cols × rows`. Plugin options above the limits throw when the plugin is
|
|
442
|
+
created. Per-graph settings are clamped and a dim note is printed under the
|
|
443
|
+
graph, e.g. `(limited: cols 100000 → 500)`. `width` is limited to
|
|
444
|
+
`limits.cols + 2`. The limits are plugin options only, so logged data can't
|
|
445
|
+
raise them:
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
graphPlugin({ limits: { cols: 500, rows: 50, precision: 20 } }); // defaults
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Per-graph settings are treated like logged data, so a graph never throws
|
|
452
|
+
because of them:
|
|
453
|
+
|
|
454
|
+
- `title`, `unit` and string axis labels are cut to `limits.stringLength`
|
|
455
|
+
(default 10,000), like message text. Other values are described
|
|
456
|
+
(`5`, `{"a":1}`), and `null` gives an empty label.
|
|
457
|
+
- An invalid `baseline`, `max` or `min` falls back to the plugin's, with a
|
|
458
|
+
dim note: `(limited: baseline NaN → min)`, or `(limited: max "x"
|
|
459
|
+
ignored)` if the plugin has none. A `max` below a numeric baseline is
|
|
460
|
+
raised to it and a `min` above it lowered to it, also when the plugin set
|
|
461
|
+
them: `(limited: max 100 → 150)`; without a numeric baseline, a `min`
|
|
462
|
+
above `max` is lowered to it. On the plugin, all of these throw.
|
|
463
|
+
- Settings that can't be read (a throwing getter, a revoked proxy) give the
|
|
464
|
+
graph with the plugin's settings and a dim note, e.g.
|
|
465
|
+
`(settings unreadable: Error: …)`.
|
|
466
|
+
|
|
467
|
+
## Columns plugin
|
|
468
|
+
|
|
469
|
+
`columnsPlugin()` lays out messages and graphs side by side. Each log is a
|
|
470
|
+
cell of a grid; `column()` picks the column the next logs go to:
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
const logger = new Logger({ timestamp: 'HH:mm:ss' }) // short, for narrow columns
|
|
474
|
+
.use(messagePlugin())
|
|
475
|
+
.use(graphPlugin({ width: 40, rows: 2 })) // graphs exactly as wide as a column
|
|
476
|
+
.use(columnsPlugin({ columns: 2, width: 40 }));
|
|
477
|
+
|
|
478
|
+
for (const column of [0, 1]) {
|
|
479
|
+
logger.column(column);
|
|
480
|
+
logger.info(
|
|
481
|
+
column === 0 ?
|
|
482
|
+
'call for call: this message wraps at the column width'
|
|
483
|
+
: 'same row'
|
|
484
|
+
);
|
|
485
|
+
logger.infoGraph(values);
|
|
486
|
+
logger.info('next row');
|
|
487
|
+
}
|
|
488
|
+
logger.endColumns();
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+

|
|
492
|
+
|
|
493
|
+
<details>
|
|
494
|
+
<summary><sub>The output above as plain text</sub></summary>
|
|
495
|
+
|
|
496
|
+
<!-- output:columns -->
|
|
497
|
+
|
|
498
|
+
```text
|
|
499
|
+
15:35:00 [i] info call for call: th | 15:35:00 [i] info same row
|
|
500
|
+
is message wraps at the column width |
|
|
501
|
+
15:35:00 [i] info | 15:35:00 [i] info
|
|
502
|
+
9.00 | 9.00
|
|
503
|
+
│ ▁▁▂▃▄▄▅▆▆██▆▆▅▄▄▃▂▁▁ │ | │ ▁▁▂▃▄▄▅▆▆██▆▆▅▄▄▃▂▁▁ │
|
|
504
|
+
│▂▂▃▄▄▅▆▇▇████████████████████▇▇▆▅▄▄▃▂▂│ | │▂▂▃▄▄▅▆▇▇████████████████████▇▇▆▅▄▄▃▂▂│
|
|
505
|
+
average: 4.76 0.00 | average: 4.76 0.00
|
|
506
|
+
min: 1.00 | min: 1.00
|
|
507
|
+
max: 9.00 | max: 9.00
|
|
508
|
+
15:35:00 [i] info next row | 15:35:00 [i] info next row
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
<!-- /output -->
|
|
512
|
+
|
|
513
|
+
<sub><i>Bars and frames may not line up in this text if your font lacks the block characters; the image shows the real alignment.</i></sub>
|
|
514
|
+
|
|
515
|
+
</details>
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
- **Call for call.** The n-th log of each column shares a row. A row is as
|
|
520
|
+
tall as its tallest cell (a wrapped message, an object, an error, a
|
|
521
|
+
graph); shorter cells get blank lines, so the next calls start on the same
|
|
522
|
+
line.
|
|
523
|
+
- **Fixed width.** Every line is exactly `width` terminal columns, padded
|
|
524
|
+
with spaces except in the last column. Colors are closed before the frame
|
|
525
|
+
and reopened on the next line, East Asian wide characters and most emoji
|
|
526
|
+
count as two columns (emoji sequences such as flags are approximate), tabs
|
|
527
|
+
become spaces. Other control characters are shown as `\xHH`, even in text
|
|
528
|
+
logged with `sanitize: 'none'`: carriage returns, cursor moves and screen
|
|
529
|
+
clears can't be laid out.
|
|
530
|
+
- **Wrapping, per kind.** `wrap` decides what happens to longer lines:
|
|
531
|
+
`'hard'` wraps at exactly the width, even mid-word; `'word'` wraps at the
|
|
532
|
+
last space that fits (longer words wrap hard); `'truncate'` cuts the line
|
|
533
|
+
with a dim `…` in the last column. The default is
|
|
534
|
+
`{ '*': 'hard', graph: 'truncate' }`: a graph wider than its column is cut,
|
|
535
|
+
a visible hint to size it with `graphPlugin({ width })`, instead of
|
|
536
|
+
leaking its bars into the next lines.
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
columnsPlugin({ wrap: 'word' }); // all kinds, graphs stay cut
|
|
540
|
+
columnsPlugin({ wrap: { message: 'word', '*': 'truncate' } });
|
|
541
|
+
columnsPlugin({ kinds: ['message', 'cpu'], wrap: { cpu: 'truncate' } }); // graphPlugin({ kind: 'cpu' })
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Kinds of any plugin are set by their kind name; a mode on its own sets
|
|
545
|
+
`'*'`. `indent: 2` starts lines created by wrapping with 2 spaces, so a
|
|
546
|
+
continuation doesn't look like a new log; the cell's own lines (object
|
|
547
|
+
keys, stack frames, graph rows) are not indented. Lines that fit cost the
|
|
548
|
+
same in every mode; word wrap adds about 15% to a wrapped line.
|
|
549
|
+
|
|
550
|
+
- **Moving around.** `column()` without an index moves to the next column;
|
|
551
|
+
after the last it writes the grid and starts a new one. Each column
|
|
552
|
+
remembers its own next row, so switching back continues where it stopped.
|
|
553
|
+
`row(n)` sends the next log to row `n` of the active column, replacing
|
|
554
|
+
that cell; `row()` leaves the cell at the cursor empty. A row is a row of
|
|
555
|
+
cells, not a terminal line.
|
|
556
|
+
- **Streaming.** A row is written as soon as every column has moved past it,
|
|
557
|
+
so memory stays small. A column that stops logging holds back at most
|
|
558
|
+
1,000 rows: older rows are then written as they are, their empty cells
|
|
559
|
+
blank. A log for a row that was already written goes to the column's next
|
|
560
|
+
free row. `endColumns()` writes the rest, empty cells
|
|
561
|
+
blank, and returns to normal output.
|
|
562
|
+
- **Live.** With `live: true` every log redraws the rows that can still
|
|
563
|
+
change, so the grid fills on screen as the calls come in and `row(n)`
|
|
564
|
+
updates a cell in place (progress, status boards). It moves the terminal
|
|
565
|
+
cursor up over its own lines, so only rows still on the screen can change;
|
|
566
|
+
older rows stay as they are. Logs of other kinds (a second message kind,
|
|
567
|
+
JSON lines) leave the drawn rows first; writes the logger doesn't see
|
|
568
|
+
(`console.log`, `stderr`) would be overwritten, and so would the screen
|
|
569
|
+
after a terminal resize. A `write` that logs to the same logger throws:
|
|
570
|
+
it would draw over the lines being drawn. Without a terminal (piped
|
|
571
|
+
or redirected output) or when the grid is wider than the terminal, it
|
|
572
|
+
falls back to streaming.
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
columnsPlugin({
|
|
576
|
+
columns: 2, // 1 to 100
|
|
577
|
+
width: 40, // per column, frame excluded; default: the terminal width
|
|
578
|
+
// (80 when unknown) split evenly, read when the plugin is created
|
|
579
|
+
frame: ' | ', // text between columns, no control characters
|
|
580
|
+
frameStyle: 'textDim', // style name of the frame
|
|
581
|
+
kinds: ['message', 'graph'], // kinds laid out in columns
|
|
582
|
+
wrap: { '*': 'hard', graph: 'truncate' }, // DEFAULT_COLUMNS_WRAP, see above
|
|
583
|
+
indent: 0, // spaces before lines created by wrapping
|
|
584
|
+
live: false, // redraw the rows on screen, see above
|
|
585
|
+
write: console.log, // line destination
|
|
586
|
+
});
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Use it after the plugins that render its kinds. Columns are a layout for
|
|
590
|
+
text kinds: JSON lines are never laid out, and `column()` throws when no
|
|
591
|
+
plugin renders the kinds. Logs filtered by level are not cells. Outside
|
|
592
|
+
`column()` … `endColumns()` the logger is not slowed down: the plugin swaps
|
|
593
|
+
the kinds' outputs only while a grid is open (set your own outputs before
|
|
594
|
+
or after, not during). Buffered rows are lost if the process exits before
|
|
595
|
+
`endColumns()`.
|
|
596
|
+
|
|
597
|
+
Limits keep one log from blocking or crashing the process, since a row's
|
|
598
|
+
output is its height times the grid's width: the grid (columns, widths and
|
|
599
|
+
frames) is at most 10,000 terminal columns wide (larger options throw), and
|
|
600
|
+
a cell at most 1,000 lines tall, the last one replaced by a dim
|
|
601
|
+
`… cut at 1000 lines`.
|
|
602
|
+
|
|
603
|
+
## JSON plugin
|
|
604
|
+
|
|
605
|
+
`jsonPlugin()` writes one JSON object per log (NDJSON), for log files and
|
|
606
|
+
collectors, and becomes the default kind. Use it **instead of**
|
|
607
|
+
`messagePlugin`, not next to it: both render the whole log, and a logger has
|
|
608
|
+
one output style (e.g. JSON in production, `messagePlugin` in development).
|
|
609
|
+
If both set the default kind, the last one used wins:
|
|
610
|
+
|
|
611
|
+
```ts
|
|
612
|
+
import { Logger, jsonPlugin } from '@mmutils/logger';
|
|
613
|
+
|
|
614
|
+
const logger = new Logger({ plugins: [jsonPlugin()] });
|
|
615
|
+
|
|
616
|
+
logger.info({ msg: 'Server started', port: 8080 });
|
|
617
|
+
logger.error({ msg: 'Query failed', err });
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
```
|
|
621
|
+
{"id":1,"time":"2026-03-16T15:35:00.000Z","type":"info","data":{"msg":"Server started","port":8080}}
|
|
622
|
+
{"id":2,"time":"2026-03-16T15:35:00.004Z","type":"error","data":{"msg":"Query failed","err":{"name":"Error","message":"connect ECONNREFUSED 127.0.0.1:5432","stack":"Error: connect ECONNREFUSED 127.0.0.1:5432\n at …","code":"ECONNREFUSED"}}}
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
jsonPlugin({
|
|
627
|
+
kind: 'json', // kind written by the plugin
|
|
628
|
+
setAsDefault: true, // make `kind` the logger's default kind
|
|
629
|
+
time: 'iso', // 'iso' | 'epoch' | false
|
|
630
|
+
limits: { items: 100, stringLength: 10_000 }, // output size, see Limits
|
|
631
|
+
escapeControls: false, // escape DEL and C1 controls too
|
|
632
|
+
writeKind: false, // write "kind" after "type"
|
|
633
|
+
write: console.log, // line destination
|
|
634
|
+
});
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
**Line shape.** The entry's properties in order: `id`, `time`, `type`,
|
|
638
|
+
`data`, then any property added by your transformers. `kind` is left out by
|
|
639
|
+
default: with one destination per kind it's always the same. Set
|
|
640
|
+
`writeKind: true` when several JSON kinds share a destination (e.g. all logs
|
|
641
|
+
to stdout in a container), so readers can tell them apart:
|
|
642
|
+
|
|
643
|
+
```ts
|
|
644
|
+
const logger = new Logger()
|
|
645
|
+
.use(jsonPlugin({ writeKind: true }))
|
|
646
|
+
.use(jsonPlugin({ kind: 'audit', setAsDefault: false, writeKind: true }));
|
|
647
|
+
logger.info({ msg: 'user deleted' }, 'audit');
|
|
648
|
+
// {"id":1,"time":…,"type":"info","kind":"audit","data":{"msg":"user deleted"}}
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
`data` is the logged value
|
|
652
|
+
as is (text, object, array, error, …), so its keys can never collide with
|
|
653
|
+
`id`, `time` or `type`. Undefined data is left out, like `JSON.stringify`
|
|
654
|
+
does.
|
|
655
|
+
|
|
656
|
+
**`time`** comes from the logger's `clock`, read once per log and shared with
|
|
657
|
+
the text timestamp. Its type depends only on the option, never on the clock,
|
|
658
|
+
and the `'precise'` clock's nanoseconds are kept:
|
|
659
|
+
|
|
660
|
+
| `time` | `'wall'` clock | `'precise'` clock |
|
|
661
|
+
| ----------------- | ----------------------------------- | ----------------------------------------- |
|
|
662
|
+
| `'iso'` (default) | `"time":"2026-03-16T15:35:00.042Z"` | `"time":"2026-03-16T15:35:00.042137508Z"` |
|
|
663
|
+
| `'epoch'` | `"time":1773675300042` | `"time":1773675300042,"ns":137508` |
|
|
664
|
+
| `false` | no time | no time |
|
|
665
|
+
|
|
666
|
+
With the logger's `timestamp: false` lines have no time and the clock isn't
|
|
667
|
+
read.
|
|
668
|
+
|
|
669
|
+
**Errors** keep what `JSON.stringify` drops (it writes `{}` for an error):
|
|
670
|
+
`name`, `message`, `stack`, own enumerable properties such as `code`, and
|
|
671
|
+
`cause`, up to 5 causes deep (a cause that loops back is `"[Circular]"`).
|
|
672
|
+
This works for errors from other realms (`vm`, workers) too.
|
|
673
|
+
|
|
674
|
+
Errors are detected **one level deep only**: the logged data itself
|
|
675
|
+
(`logger.error(err)`) and its direct properties (`logger.error({ err })`).
|
|
676
|
+
This is a performance decision: ordinary data costs a single native
|
|
677
|
+
`JSON.stringify`, and finding errors anywhere would mean walking every value
|
|
678
|
+
of every log. Errors nested deeper are left to native JSON, which keeps only
|
|
679
|
+
their own enumerable properties, so their message and stack are lost:
|
|
680
|
+
|
|
681
|
+
```ts
|
|
682
|
+
logger.error({ err }); // {"err":{"name":"Error","message":"…","stack":"…","code":"ECONNREFUSED"}}
|
|
683
|
+
logger.error({ request: { err } }); // {"request":{"err":{"code":"ECONNREFUSED"}}}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Put the errors you log at the top or under a direct property.
|
|
687
|
+
|
|
688
|
+
**Values JSON can't represent** are written as JSON that keeps their meaning,
|
|
689
|
+
never as invalid JSON or source code:
|
|
690
|
+
|
|
691
|
+
| Value | Written as |
|
|
692
|
+
| ---------------------------- | -------------------------------------------- |
|
|
693
|
+
| bigint | a JSON number with every digit |
|
|
694
|
+
| function, class | `"[Function: name]"`, `"[class Name]"` |
|
|
695
|
+
| symbol | `"Symbol(description)"` |
|
|
696
|
+
| buffer, typed array | `"<Buffer 68 69>"`, `"Uint8Array(2) [1, 2]"` |
|
|
697
|
+
| map, set, regular expression | `"Map(1) {\"a\" => 1}"`, `"/x/g"` |
|
|
698
|
+
| throwing getter | `"[Getter threw: Error: …]"` |
|
|
699
|
+
| circular, revoked proxy | `"[Unserializable: TypeError: …]"` |
|
|
700
|
+
|
|
701
|
+
Logging never throws because of the data, and every line is valid JSON; the
|
|
702
|
+
other properties still render. Errors of `write` propagate.
|
|
703
|
+
|
|
704
|
+
**`limits`** keep lines bounded and valid JSON, and **keep each value's
|
|
705
|
+
type**, so collectors that map fields by type (e.g. Elasticsearch, which
|
|
706
|
+
rejects a document whose field changes from array to string) accept the
|
|
707
|
+
line:
|
|
708
|
+
|
|
709
|
+
- strings longer than `stringLength` are cut: `"xxx… 90 more characters"`;
|
|
710
|
+
- arrays and objects with more than `items` elements or keys keep the first
|
|
711
|
+
`items`, followed by a `"… 3 more items"` element or a
|
|
712
|
+
`"…": "… 3 more items"` property. This applies to the data and its direct
|
|
713
|
+
properties (one level deep, like errors); deeper objects are written by
|
|
714
|
+
native JSON;
|
|
715
|
+
- an array or object whose JSON is longer than `stringLength` is cut
|
|
716
|
+
structurally: elements or properties are kept while they fit (about
|
|
717
|
+
`stringLength` characters, plus the note), then the same "more" note. A
|
|
718
|
+
first element that alone doesn't fit is cut the same way, up to 4 levels
|
|
719
|
+
deep, then becomes a cut string;
|
|
720
|
+
- an element that can't be serialized becomes a placeholder and the rest
|
|
721
|
+
keeps its type; a value that contains itself is `"[Circular]"`.
|
|
722
|
+
|
|
723
|
+
Cutting costs time in proportion to `stringLength`, not to the value, and
|
|
724
|
+
ordinary data never reaches it.
|
|
725
|
+
|
|
726
|
+
Ordinary data costs one native `JSON.stringify` after a quick check of its
|
|
727
|
+
values; only data with errors, binary data, bigints, functions, long strings
|
|
728
|
+
or arrays is serialized property by property.
|
|
729
|
+
|
|
730
|
+
**`escapeControls`**: `JSON.stringify` already escapes newlines, ESC and the
|
|
731
|
+
other C0 controls, so logged text can't start a new line or send a terminal
|
|
732
|
+
escape sequence. It writes DEL and C1 controls (`U+007F`–`U+009F`) as they
|
|
733
|
+
are. They're harmless for log collectors, but some terminals act on C1
|
|
734
|
+
controls when lines are shown raw (`cat`, `tail -f`). `escapeControls: true`
|
|
735
|
+
writes them as `\u00XX`: the parsed JSON is the same, at about 1 ns per
|
|
736
|
+
character.
|
|
737
|
+
|
|
738
|
+
A JSON kind can also live next to messages, e.g. an audit trail:
|
|
739
|
+
|
|
740
|
+
```ts
|
|
741
|
+
const logger = new Logger()
|
|
742
|
+
.use(messagePlugin())
|
|
743
|
+
.use(jsonPlugin({ kind: 'audit', setAsDefault: false, write: toAuditFile }));
|
|
744
|
+
|
|
745
|
+
logger.info('Server started'); // styled message
|
|
746
|
+
logger.info({ action: 'login', user: 'alice' }, 'audit'); // JSON line
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
## Styles
|
|
750
|
+
|
|
751
|
+
Styles are named escape-code strings, baked once per level. The bundled
|
|
752
|
+
plugins use:
|
|
753
|
+
|
|
754
|
+
| Style | Default | Used for |
|
|
755
|
+
| --------------- | ------------------------- | -------------------------------------------------- |
|
|
756
|
+
| `textDim` | gray (bright black) | date, id, symbol, units, axis labels, stack frames |
|
|
757
|
+
| `textNormal` | terminal default | object keys, legend labels |
|
|
758
|
+
| `textHighlight` | level color, bold | messages, values, graph title, bars and frame |
|
|
759
|
+
| `textBadge` | level color as background | the type badge |
|
|
760
|
+
|
|
761
|
+
The level colors are the terminal's 16 basic colors, so they follow its
|
|
762
|
+
theme: red for `fatal` (its badge is white on red), bright red for `error`,
|
|
763
|
+
yellow for `warning`, blue for `info`, magenta for `debug`, cyan for
|
|
764
|
+
`trace`, gray for `verbose`.
|
|
765
|
+
|
|
766
|
+
### Symbols
|
|
767
|
+
|
|
768
|
+
The message and graph plugins show a symbol in front of the type badge:
|
|
769
|
+
|
|
770
|
+
| Type | `fatal` | `error` | `warning` | `info` | `debug` | `trace` | `verbose` |
|
|
771
|
+
| ------ | ------- | ------- | --------- | ------ | ------- | ------- | --------- |
|
|
772
|
+
| Symbol | `[X]` | `[!]` | `/!\` | `[i]` | `(?)` | `/\/` | `...` |
|
|
773
|
+
|
|
774
|
+
They are plain ASCII, which every terminal and font draws, one column per
|
|
775
|
+
character. `symbol` takes symbols per type, merged over these
|
|
776
|
+
(`DEFAULT_SYMBOLS`) and padded with spaces to the widest one plus one, so the
|
|
777
|
+
badges line up. A string is one symbol for every type, used as is; `''` shows
|
|
778
|
+
none:
|
|
779
|
+
|
|
780
|
+
```ts
|
|
781
|
+
messagePlugin({ symbol: { info: '->', debug: '#' } }); // others stay
|
|
782
|
+
messagePlugin({ symbol: '' }); // no symbols, e.g. in narrow columns
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
Emoji work too, but some fonts don't have them and terminals measure some of
|
|
786
|
+
them differently (newer emoji such as 🪰, and symbols followed by an emoji
|
|
787
|
+
variation selector such as `⚠️`): a terminal that measures one differently
|
|
788
|
+
from @mmutils/logger shifts that badge by a column.
|
|
789
|
+
|
|
790
|
+
### Changing styles
|
|
791
|
+
|
|
792
|
+
Override them per level, or for all levels with `'*'`. Values are strings or
|
|
793
|
+
[`style-escape-codes`](https://www.npmjs.com/package/style-escape-codes)
|
|
794
|
+
chains:
|
|
795
|
+
|
|
796
|
+
```ts
|
|
797
|
+
import { sec } from 'style-escape-codes';
|
|
798
|
+
|
|
799
|
+
messagePlugin({
|
|
800
|
+
styles: {
|
|
801
|
+
'*': { textDim: sec.dim },
|
|
802
|
+
error: { textBadge: sec.b.bgRed.fgWhite },
|
|
803
|
+
},
|
|
804
|
+
});
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
Within one style sheet, level groups override `'*'`. `reset` is built in.
|
|
808
|
+
A plugin's `styles` are merged into its default sheet, so `'*'` there can't
|
|
809
|
+
replace a style the defaults set per level (`textHighlight`, `textBadge`):
|
|
810
|
+
set those per level, or for all levels in a later sheet.
|
|
811
|
+
|
|
812
|
+
Style names are shared by all plugins of a logger, and later sheets override
|
|
813
|
+
earlier ones. Each bundled plugin registers the default styles, so overrides
|
|
814
|
+
passed to `messagePlugin` are undone by a `graphPlugin()` used after it. To
|
|
815
|
+
restyle a logger with several plugins, add a sheet after them:
|
|
816
|
+
|
|
817
|
+
```ts
|
|
818
|
+
const logger = new Logger({ plugins: [messagePlugin(), graphPlugin()] });
|
|
819
|
+
logger.addStyles({ '*': { textDim: sec.dim } }); // all plugins, all levels
|
|
820
|
+
logger.addStyles({ '*': { textBadge: sec.invert } }); // overrides per-level defaults too
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
`useStyles: false` on the logger bakes every style to an empty string, e.g.
|
|
824
|
+
for log files or `useStyles: !process.env.NO_COLOR`; it costs nothing per
|
|
825
|
+
log.
|
|
826
|
+
|
|
827
|
+
## Writing a plugin
|
|
828
|
+
|
|
829
|
+
A plugin is an object. Usually it is returned by a factory that takes the
|
|
830
|
+
plugin's own typed options, so no global configuration is needed.
|
|
831
|
+
|
|
832
|
+
```ts
|
|
833
|
+
import type { LoggerPlugin, LogType } from '@mmutils/logger';
|
|
834
|
+
|
|
835
|
+
interface CsvMethods {
|
|
836
|
+
/** Logs `values` as one CSV row. */
|
|
837
|
+
csv(values: readonly unknown[], type?: LogType): void;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export const csvPlugin = (): LoggerPlugin<CsvMethods> => ({
|
|
841
|
+
name: 'csv',
|
|
842
|
+
templateMaps: {
|
|
843
|
+
// per-kind templates, applied to each entry property
|
|
844
|
+
csv: { data: (value) => (value as unknown[]).map(String).join(',') },
|
|
845
|
+
},
|
|
846
|
+
outputs: {
|
|
847
|
+
// per-kind output; ctx.time: time of the log (whole epoch ms), ctx.timeNs:
|
|
848
|
+
// nanoseconds within it with the precise clock
|
|
849
|
+
csv: (entry, ctx) =>
|
|
850
|
+
process.stdout.write(`${ctx.time ?? ''},${entry.type},${entry.data}\n`),
|
|
851
|
+
},
|
|
852
|
+
methods: (logger) => ({
|
|
853
|
+
csv: (values, type = 'info') => logger.log(type, values, 'csv'),
|
|
854
|
+
}),
|
|
855
|
+
});
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
A plugin can provide:
|
|
859
|
+
|
|
860
|
+
- `styles`: style sheets available to all templates.
|
|
861
|
+
- `templateMaps`: functions keyed by entry property, per kind.
|
|
862
|
+
- `transformers`: pipeline steps `(entry, ctx) => entry`, with an optional
|
|
863
|
+
position. Copy keys from data with a spread (`{ ...entry, ...fields }`),
|
|
864
|
+
not with `Object.assign` or `entry[key] = value`: those treat a `__proto__`
|
|
865
|
+
key (e.g. from parsed JSON) as the entry's prototype, whose keys would then
|
|
866
|
+
be printed.
|
|
867
|
+
- `outputs`: the final destination, per kind.
|
|
868
|
+
- `methods`: returns a plain object of methods added to the logger, typed
|
|
869
|
+
through `LoggerPlugin<M>`. Class instances are rejected: their prototype
|
|
870
|
+
methods would not be copied.
|
|
871
|
+
- `defaultKind`: makes the plugin's kind the default.
|
|
872
|
+
|
|
873
|
+
Registration is atomic: every part is checked first (functions are
|
|
874
|
+
functions, styles convert to strings, methods override nothing, including
|
|
875
|
+
symbol keys), so a plugin that throws leaves the logger unchanged and can be
|
|
876
|
+
fixed and used again.
|
|
877
|
+
|
|
878
|
+
Re-declaring an existing logger method in `M` replaces its signature and docs
|
|
879
|
+
in autocomplete. That's how `messagePlugin` changes `info(data: unknown)` to
|
|
880
|
+
`info(message: string)`.
|
|
881
|
+
|
|
882
|
+
## Development
|
|
883
|
+
|
|
884
|
+
```sh
|
|
885
|
+
npm install
|
|
886
|
+
npm run check # format, lint, typecheck, tests
|
|
887
|
+
npm run build # dist/esm, dist/cjs, dist/types
|
|
888
|
+
npm run demo # feature tour (after build)
|
|
889
|
+
npm run images # README images in assets/ and their text, from real output (after build)
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
## AI use
|
|
893
|
+
|
|
894
|
+
@mmutils/logger is developed with AI assistance: most of the code, tests,
|
|
895
|
+
benchmarks and documentation was written with AI. The author designs the
|
|
896
|
+
features, makes the decisions and reviews the changes, and every change
|
|
897
|
+
passes the same checks (formatting, lint, strict type checking and tests)
|
|
898
|
+
before it is committed.
|
|
899
|
+
|
|
900
|
+
## License
|
|
901
|
+
|
|
902
|
+
[MIT](LICENSE)
|