@mmutils/logger 0.0.0-stage → 0.1.1

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1038 -2
  3. package/dist/cjs/index.cjs +72 -0
  4. package/dist/cjs/index.cjs.map +1 -0
  5. package/dist/cjs/levels.cjs +82 -0
  6. package/dist/cjs/levels.cjs.map +1 -0
  7. package/dist/cjs/logger.cjs +576 -0
  8. package/dist/cjs/logger.cjs.map +1 -0
  9. package/dist/cjs/outputs.cjs +24 -0
  10. package/dist/cjs/outputs.cjs.map +1 -0
  11. package/dist/cjs/plugins/columns.cjs +782 -0
  12. package/dist/cjs/plugins/columns.cjs.map +1 -0
  13. package/dist/cjs/plugins/graph.cjs +578 -0
  14. package/dist/cjs/plugins/graph.cjs.map +1 -0
  15. package/dist/cjs/plugins/json.cjs +644 -0
  16. package/dist/cjs/plugins/json.cjs.map +1 -0
  17. package/dist/cjs/plugins/message.cjs +108 -0
  18. package/dist/cjs/plugins/message.cjs.map +1 -0
  19. package/dist/cjs/plugins/shared.cjs +194 -0
  20. package/dist/cjs/plugins/shared.cjs.map +1 -0
  21. package/dist/cjs/time.cjs +234 -0
  22. package/dist/cjs/time.cjs.map +1 -0
  23. package/dist/cjs/utils/text.cjs +646 -0
  24. package/dist/cjs/utils/text.cjs.map +1 -0
  25. package/dist/esm/index.js +11 -0
  26. package/dist/esm/index.js.map +1 -0
  27. package/dist/esm/levels.js +72 -0
  28. package/dist/esm/levels.js.map +1 -0
  29. package/dist/esm/logger.js +572 -0
  30. package/dist/esm/logger.js.map +1 -0
  31. package/dist/esm/outputs.js +21 -0
  32. package/dist/esm/outputs.js.map +1 -0
  33. package/dist/esm/plugins/columns.js +778 -0
  34. package/dist/esm/plugins/columns.js.map +1 -0
  35. package/dist/esm/plugins/graph.js +572 -0
  36. package/dist/esm/plugins/graph.js.map +1 -0
  37. package/dist/esm/plugins/json.js +641 -0
  38. package/dist/esm/plugins/json.js.map +1 -0
  39. package/dist/esm/plugins/message.js +105 -0
  40. package/dist/esm/plugins/message.js.map +1 -0
  41. package/dist/esm/plugins/shared.js +187 -0
  42. package/dist/esm/plugins/shared.js.map +1 -0
  43. package/dist/esm/time.js +231 -0
  44. package/dist/esm/time.js.map +1 -0
  45. package/dist/esm/utils/text.js +621 -0
  46. package/dist/esm/utils/text.js.map +1 -0
  47. package/dist/types/index.d.ts +11 -0
  48. package/dist/types/levels.d.ts +50 -0
  49. package/dist/types/logger.d.ts +245 -0
  50. package/dist/types/outputs.d.ts +12 -0
  51. package/dist/types/plugins/columns.d.ts +151 -0
  52. package/dist/types/plugins/graph.d.ts +243 -0
  53. package/dist/types/plugins/json.d.ts +169 -0
  54. package/dist/types/plugins/message.d.ts +181 -0
  55. package/dist/types/plugins/shared.d.ts +74 -0
  56. package/dist/types/time.d.ts +48 -0
  57. package/dist/types/types.d.ts +176 -0
  58. package/dist/types/utils/text.d.ts +231 -0
  59. package/package.json +85 -4
package/README.md CHANGED
@@ -1,3 +1,1039 @@
1
- # Temporary Holding Version
1
+ # @mmutils/logger
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ![Quick start output: a message, an object, an error and a bar graph, in color](assets/quick-start.svg)
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
+ <!-- code:quick-start -->
82
+
83
+ ```ts
84
+ import { Logger, messagePlugin, graphPlugin } from '@mmutils/logger';
85
+
86
+ const logger = new Logger({
87
+ level: 'debug',
88
+ plugins: [messagePlugin(), graphPlugin()],
89
+ });
90
+
91
+ logger.info('Server started');
92
+ logger.warning({ user: 'alice', retries: 3 });
93
+ logger.error(new Error('Connection lost'));
94
+ logger.infoGraph([1, 3, 8, 21, 8, 3], { title: 'CPU', unit: '%' });
95
+ ```
96
+
97
+ <!-- /code -->
98
+
99
+ This prints the sample at the top
100
+ ([`examples/quick-start.js`](examples/quick-start.js)); every feature has
101
+ its own example, see [Examples](#examples). Plugins can also be chained,
102
+ with the same typing:
103
+
104
+ ```ts
105
+ const logger = new Logger().use(messagePlugin()).use(graphPlugin());
106
+ ```
107
+
108
+ ## Without plugins
109
+
110
+ The core logger outputs the log entry object itself:
111
+
112
+ ```ts
113
+ new Logger().info([1, 2, 3, 4]);
114
+ // { id: 1, timestamp: '2026/03/16 15:35:00', type: 'info', kind: 'default', data: [1, 2, 3, 4] }
115
+ ```
116
+
117
+ Every `log(type, data, kind)` call:
118
+
119
+ 1. checks the type against `level`; filtered logs return before anything is
120
+ created;
121
+ 2. creates the entry `{ id, timestamp, type, kind, data }` (no `timestamp`
122
+ with `timestamp: false`);
123
+ 3. runs every function in `transformers`. The first is
124
+ `templateTransformer`, which passes each entry property through the
125
+ template map of the entry's kind (`templateMaps[kind]`);
126
+ 4. hands the result to the output of the kind, or to the default `output`.
127
+
128
+ Plugins hook into steps 3 and 4, and so can you:
129
+
130
+ | Member | Description |
131
+ | ------------------------------- | -------------------------------------------------------------- |
132
+ | `use(plugin)` | Registers a plugin; returns the logger typed with its methods. |
133
+ | `hasPlugin(name)` | Whether a plugin with that name is used. |
134
+ | `addTransformer(fn, position?)` | Inserts a pipeline step, at the end by default. |
135
+ | `removeTransformer(fn)` | Removes a step; `true` if it was found. |
136
+ | `replaceTransformer(old, fn)` | Replaces a step in place; `true` if it was found. |
137
+ | `addTemplateMap(kind, map)` | Adds template functions for a kind, merged with existing ones. |
138
+ | `setOutput(kind, output)` | Sets the output of a kind. |
139
+ | `getOutput(kind)` | The output of a kind, or the default output. |
140
+ | `addStyles(sheet)` | Adds a style sheet and rebakes the styles, see Styles. |
141
+ | `getStyles(type)` | The baked styles of a log type. |
142
+ | `level`, `defaultKind` | Can be changed at any time. |
143
+ | `transformers`, `templateMaps` | The pipeline, readable and mutable. |
144
+
145
+ ### Options
146
+
147
+ | Option | Default | Description |
148
+ | ------------- | ------------------------------- | -------------------------------------------------------------------------------------- |
149
+ | `level` | `'info'` | Minimum level: `silent` `fatal` `error` `warning` `info` `debug` `trace` `verbose`. |
150
+ | `plugins` | `[]` | Plugins to use, in order. Their methods are typed. |
151
+ | `timestamp` | `'YYYY/MM/DD HH:mm:ss'` (local) | A pattern, `{ format?, utc? }`, `(time, timeNs) => string` or `false`. See Timestamps. |
152
+ | `clock` | `'wall'` | `'wall'` (`Date.now()`), `'precise'` (nanoseconds) or `() => epochMs`. See Timestamps. |
153
+ | `useStyles` | `true` | `false` bakes every style to an empty string. |
154
+ | `output` | `console.log` | Output for kinds without a plugin output. |
155
+ | `defaultKind` | `'default'` | Kind of logs without an explicit kind. |
156
+
157
+ Log methods: `fatal`, `error`, `warning`, `info`, `debug`, `trace`, `verbose`,
158
+ and `log(type, data, kind?)`. `logger.level` can be changed at any time.
159
+ Levels and types are checked at runtime too, since levels often come from
160
+ configuration or environment variables as plain strings: an unknown level
161
+ (e.g. `'warn'`) throws a `RangeError` instead of silently turning off all
162
+ logs, and an unknown type (or `'silent'`, a level only) throws a
163
+ `TypeError` from `log`.
164
+
165
+ ### Timestamps
166
+
167
+ The timestamp is when `log` was called. It's a dayjs-style pattern, compiled
168
+ once when the logger is created:
169
+
170
+ ```ts
171
+ new Logger(); // 2026/03/16 15:35:00 (DEFAULT_TIMESTAMP_FORMAT, local time)
172
+ new Logger({ timestamp: 'HH:mm:ss.SSS' }); // 15:35:00.042
173
+ new Logger({ timestamp: { format: 'YYYY-MM-DD[T]HH:mm:ss[Z]', utc: true } });
174
+ new Logger({ timestamp: 'h:mm:ss A' }); // 3:35:00 PM
175
+ new Logger({ clock: 'precise', timestamp: 'ss.SSSSSSSSS EEE' }); // 00.042137508 +12.345678ms
176
+ new Logger({ timestamp: (time, timeNs) => myFormat(time) }); // any formatter
177
+ new Logger({ timestamp: false }); // no timestamp, the clock isn't read
178
+ ```
179
+
180
+ | Token | Output |
181
+ | -------------------------- | -------------------------------------------------------- |
182
+ | `YYYY` `YY` | `2026` `26` |
183
+ | `MMMM` `MMM` `MM` `M` | `March` `Mar` `03` `3` |
184
+ | `DD` `D`, `dddd` `ddd` | `16` `16`, `Monday` `Mon` |
185
+ | `HH` `H` | `15` `15` (24 hours) |
186
+ | `hh` `h` `A` `a` | `03` `3` `PM` `pm` (12 hours) |
187
+ | `mm` `m`, `ss` `s` | minutes, seconds |
188
+ | `SSS` `SSSSSS` `SSSSSSSSS` | milliseconds, microseconds, nanoseconds |
189
+ | `Z` `ZZ` | `+02:00` `+0200` |
190
+ | `E` | elapsed since the logger was created: `+1s 32ms 400µs` |
191
+ | `EEE` | elapsed in milliseconds: `+1032.400123ms` |
192
+ | `[text]` | `text` as is (other letters may be tokens: use brackets) |
193
+
194
+ **Clocks.** `'wall'` (default) is `Date.now()`: the system's wall time, in
195
+ milliseconds. `'precise'` is `process.hrtime` anchored to `Date.now()` when
196
+ the logger is created: exact nanoseconds and monotonic, for benchmarks and
197
+ tracing; like any monotonic clock it can drift from wall time on
198
+ long-running processes (NTP corrections, sleep). A function returning epoch
199
+ milliseconds replaces the clock, e.g. a fixed time in tests. Durations you
200
+ measure yourself belong in the data (`{ took: 300 }`), not in the timestamp.
201
+
202
+ **Performance.** Text that changes at most once per second is built with
203
+ `Date` getters (no `Intl`) and cached; a log pays the clock read (about
204
+ 70 ns), a comparison, and its fraction tokens. `E` costs about 1 µs per log
205
+ (`formatHighResTime`), `EEE` about 0.25 µs. Change the default pattern in one
206
+ place: `DEFAULT_TIMESTAMP_FORMAT`.
207
+
208
+ ## Message plugin
209
+
210
+ `messagePlugin()` renders logs as styled text and becomes the default kind, so
211
+ every `logger.info(...)` is a message. Text stays on the header line; objects,
212
+ arrays and errors start on the next line, one property (or stack frame) per
213
+ line.
214
+
215
+ ```ts
216
+ messagePlugin({
217
+ showId: false, // show #id before the date
218
+ symbol: DEFAULT_SYMBOLS, // per type in front of the badge, see Symbols
219
+ kind: 'message', // kind rendered by the plugin
220
+ setAsDefault: true, // make `kind` the logger's default kind
221
+ sanitize: 'safe', // control characters of logged text, see Sanitize
222
+ limits: { items: 100, stringLength: 10_000 }, // output size, see Limits
223
+ styles: {}, // style overrides, see Styles
224
+ write: console.log, // string destination
225
+ });
226
+ ```
227
+
228
+ ### Limits
229
+
230
+ Like `util.inspect`, the message plugin caps what a single log prints, so
231
+ logging a large buffer, array or string can't block the process or flood
232
+ the output:
233
+
234
+ - `items` (default `100`): array, buffer and typed array elements, object
235
+ keys, map and set entries and stack frames. Buffers and typed arrays print
236
+ on one line: `<Buffer 68 65 6c … 1048573 more bytes>`.
237
+ - `stringLength` (default `10_000`): characters of a message, key, error
238
+ message or value. Text is cut before it is sanitized; string values are
239
+ cut before JSON encoding.
240
+
241
+ What was left out is noted, e.g. `… 999900 more items`. Nested objects are
242
+ still serialized by the native `JSON.stringify` before their text is cut.
243
+ `Infinity` turns a limit off:
244
+
245
+ ```ts
246
+ messagePlugin({ limits: { items: Infinity, stringLength: Infinity } });
247
+ ```
248
+
249
+ The graph plugin takes the same `items` and `stringLength` in its `limits`,
250
+ for data that is not a graph.
251
+
252
+ ### Sanitize
253
+
254
+ By default, logged text (strings, object keys and values, errors, graph
255
+ titles and labels) can't send terminal escape sequences (cursor moves, screen
256
+ clears, window titles, hyperlinks, clipboard writes) or start a line that
257
+ looks like a log header. `sanitize` is available on both bundled plugins:
258
+
259
+ | `sanitize` | Terminal sequences | Colors (`\x1B[...m`) | Newlines |
260
+ | ------------------ | ------------------ | -------------------- | --------------------------- |
261
+ | `'safe'` (default) | `\xHH` | kept, then a reset | continuation lines indented |
262
+ | `'strict'` | `\xHH` | `\xHH` | `\x0A`, one line per log |
263
+ | `'none'` | kept | kept | kept, only for trusted text |
264
+ | custom rules | `\xHH` | `colors: 'keep'` | `newlines: 'indent'` |
265
+ | | | or `'escape'` | or `'keep'` or `'escape'` |
266
+
267
+ ```ts
268
+ messagePlugin({ sanitize: { colors: 'escape', newlines: 'keep' } });
269
+ ```
270
+
271
+ Single line text (object keys, stack frames, graph titles and labels) always
272
+ has its newlines escaped. Clean text costs one regex scan and no allocation;
273
+ `'none'` costs nothing.
274
+
275
+ ## Graph plugin
276
+
277
+ `graphPlugin()` draws bar graphs and adds one method per level: `fatalGraph`,
278
+ `errorGraph`, `warningGraph`, `infoGraph`, `debugGraph`, `traceGraph`,
279
+ `verboseGraph`. Settings can be set on the plugin and overridden per graph.
280
+
281
+ ```ts
282
+ logger.infoGraph(values, {
283
+ cols: 30, // bars; the frame adds 2, so 32 terminal columns
284
+ // width: 32, // or the total width, frame included (not with cols)
285
+ rows: 3, // bar rows
286
+ title: 'CPU',
287
+ unit: '%', // appended to values
288
+ precision: 2,
289
+ max: 100, // top, default: from the values, see below
290
+ min: 0, // bottom, default: from the values
291
+ baseline: 0, // where bars start: a value, 'min' or 'max', see below
292
+ aggregate: 'average', // 'average' | 'max' | 'min', see below
293
+ showAverage: true,
294
+ showMin: true,
295
+ showMax: true,
296
+ axis: { start: 0, end: 1032.4, unit: 'ms' }, // optional axis row
297
+ });
298
+ ```
299
+
300
+ For example, from [`examples/graphs.js`](examples/graphs.js), with the
301
+ datasets of [`examples/data.js`](examples/data.js):
302
+
303
+ <!-- code:graph -->
304
+
305
+ ```ts
306
+ logger.infoGraph(latency, {
307
+ title: 'Latency',
308
+ unit: 'ms',
309
+ precision: 0,
310
+ aggregate: 'max', // the slowest request of each bar
311
+ axis: { start: 0, end: 1032.4, unit: 'ms', capTo: 1 }, // a time axis
312
+ });
313
+ logger.warningGraph(queue, {
314
+ title: 'Queue',
315
+ rows: 2,
316
+ axis: { start: 'Mon', end: 'Fri' },
317
+ });
318
+ logger.infoGraph(temperatures, {
319
+ title: 'Temperature',
320
+ unit: '°C',
321
+ precision: 1,
322
+ rows: 4,
323
+ baseline: 20, // warmer days grow up, colder days down
324
+ axis: { start: 'Mar 2', end: 'Mar 15' },
325
+ });
326
+ ```
327
+
328
+ <!-- /code -->
329
+
330
+ ![A latency graph with max aggregation and a time axis, a queue graph with day labels, and a temperature graph split at 20 °C](assets/graph.svg)
331
+
332
+ <details>
333
+ <summary><sub>The output above as plain text</sub></summary>
334
+
335
+ <!-- output:graph -->
336
+
337
+ ```text
338
+ 2026/03/16 15:35:00 [i] info
339
+ Latency 370 ms
340
+ │ ▇ ▇ ▃ ▆ █ ▃ │
341
+ │▃▄█▅▅▄█▃▂ █ ▁▂█▄▅▅█▄▃▂▁█ │
342
+ │████████████▇█████████████▇▇▇█│
343
+ 0ms 1s 32ms
344
+ average: 154 ms 0 ms
345
+ min: 105 ms
346
+ max: 370 ms
347
+ 2026/03/16 15:35:00 /!\ warning
348
+ Queue 9.00
349
+ │ ▁▁▃▃▄▆▆██▆▆▄▃▃▁▁ │
350
+ │▂▂▄▄▅▇▇████████████████▇▇▅▄▄▂▂│
351
+ Mon Fri
352
+ average: 4.76 0.00
353
+ min: 1.00
354
+ max: 9.00
355
+ 2026/03/16 15:35:00 [i] info
356
+ Temperature 25.8 °C
357
+ │ ▂▃▆▇██▆▄▁ │
358
+ │▄▄█████████▄▃ ▁▁▁▁│
359
+ │ ▀▀████████▀▔▔ │
360
+ │ ▔▔▀▀▀▀▔ │
361
+ Mar 2 Mar 15
362
+ average: 20.4 °C 15.4 °C
363
+ baseline: 20.0 °C
364
+ min: 15.4 °C
365
+ max: 25.8 °C
366
+ ```
367
+
368
+ <!-- /output -->
369
+
370
+ <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>
371
+
372
+ </details>
373
+
374
+ ---
375
+
376
+ The plugin takes the same settings, as defaults for all its graphs, plus:
377
+
378
+ ```ts
379
+ graphPlugin({
380
+ // cols, width, rows, title, unit, … as above
381
+ limits: { cols: 500, rows: 50, precision: 20 }, // see below
382
+ kind: 'graph', // kind rendered by the plugin
383
+ showId: false, // show #id before the date
384
+ symbol: DEFAULT_SYMBOLS, // per type in front of the badge, see Symbols
385
+ sanitize: 'safe', // see Sanitize
386
+ styles: {}, // style overrides, see Styles
387
+ write: console.log, // string destination
388
+ });
389
+ ```
390
+
391
+ Defaults: `cols: 30`, `rows: 3`, `precision: 2`, `aggregate: 'average'`,
392
+ `baseline: 0`, empty `title` and `unit`, all statistics shown, no `max`,
393
+ `min` and `axis`.
394
+ Data that isn't an array of numbers, e.g.
395
+ `logger.log('info', 'text', 'graph')`, is printed as text, limited like
396
+ messages (`limits.items`, `limits.stringLength`).
397
+
398
+ **`cols` or `width`.** `cols` is the resolution: the number of bars values are
399
+ scaled to, like `rows` for the height. The `│` frame adds 2 characters, so
400
+ `cols: 64` takes 66 terminal columns. `width` sets the space instead:
401
+ `width: 64` draws 62 bars and every row of the graph is exactly 64 terminal
402
+ columns wide, e.g. to fit a layout column. Wide characters in titles and
403
+ labels (CJK, most emoji) count as two columns, as in `columnsPlugin`. Set one
404
+ of them on the plugin (both throw); per graph, `width` wins.
405
+
406
+ **Statistics** (average, min, max) and the default top and bottom are
407
+ computed from your original values, so they never depend on the graph width.
408
+ The top value is shown on the title row, the bottom value on the average
409
+ row.
410
+
411
+ **`aggregate`** decides how values sharing a column are drawn: `'average'` for
412
+ a smooth trend, `'max'` to keep spikes visible (latency, errors), `'min'` to
413
+ keep dips visible.
414
+
415
+ **`baseline`** is where the bars start: they grow up to values above it and
416
+ down to values below it, in the level's color. It is a value (`0` by
417
+ default), `'min'` (the bottom of the graph) or `'max'` (the top). For values
418
+ from -10 to 10:
419
+
420
+ | `baseline` | Bars |
421
+ | ---------------- | -------------------------------------- |
422
+ | `0` (default) | split at 0: up above it, down below it |
423
+ | `'min'` | only up, from -10 |
424
+ | `'max'` | only down, from 10 |
425
+ | `20` (any value) | only down, from 20: the range holds it |
426
+
427
+ The first three on a wave from -8 to 8
428
+ ([`examples/graph-baseline.js`](examples/graph-baseline.js) has a realistic
429
+ case for each):
430
+
431
+ ![The same wave with baseline 0 (split), 'min' (bars up) and 'max' (bars down), side by side](assets/baseline.svg)
432
+
433
+ <details>
434
+ <summary><sub>The output above as plain text</sub></summary>
435
+
436
+ <!-- output:baseline -->
437
+
438
+ ```text
439
+ info | info | info
440
+ baseline: 0 8 | baseline: 'min' 8 | baseline: 'max' 8
441
+ │ ▄▆██▆▄ │ | │ ▄▆██▆▄ │ | │███▀▔ ▔▀█████████████│
442
+ │ ▄████████▄ │ | │ ▄████████▄ │ | │█▀ ▀███████████│
443
+ │ ▀████████▀│ | │████████████▄ ▄│ | │ ▀████████▀│
444
+ │ ▀▀██▀▀ │ | │██████████████▄▂ ▂▄██│ | │ ▀▀██▀▀ │
445
+ average: 0 -8 | average: 0 -8 | average: 0 -8
446
+ baseline: 0 | min: -8 | min: -8
447
+ min: -8 | max: 8 | max: 8
448
+ max: 8 | |
449
+ ```
450
+
451
+ <!-- /output -->
452
+
453
+ <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>
454
+
455
+ </details>
456
+
457
+ ---
458
+
459
+ ```ts
460
+ logger.infoGraph(temperatures, {
461
+ title: 'Temperature',
462
+ unit: '°C',
463
+ rows: 4, // a split graph needs 2 or more rows to show both sides
464
+ baseline: 20, // warmer days grow up, colder days down
465
+ });
466
+ ```
467
+
468
+ - A value is always in the range, so values from 80 to 85 draw bars from 0
469
+ and keep their proportions. Use it for values with a sign or a target:
470
+ profit and loss, a temperature setpoint, latency against a budget.
471
+ - `'min'` zooms in on values far from zero, e.g. a heap between 480 and
472
+ 515 MB; the smallest value sits on the bottom line. `'max'` hangs every
473
+ bar from the top, e.g. how far a battery has drained.
474
+ - A split graph places the baseline in proportion to the range, at the row
475
+ boundary that gives the bars the most room. Both sides share one scale,
476
+ so equal distances from the baseline draw equal bars, and a `baseline`
477
+ row follows the average.
478
+ - A side gets rows only if its longest bar draws at least an eighth of a
479
+ row: -0.01 next to 100 sits on the baseline instead of taking a row.
480
+ - Bars growing down end in the nearest upper block: `▔` (an eighth of a
481
+ row), `▀` (a half) or `█`, so their tips are coarser than those of bars
482
+ growing up. These are the only upper blocks every terminal and font
483
+ draws; the finer ones (`🮂 🮃 🮄 🮅 🮆`) are missing from many.
484
+ - With `aggregate: 'average'`, a column holding values on both sides
485
+ averages toward the baseline; use `'max'` or `'min'` to keep one side's
486
+ extremes.
487
+
488
+ **`max` and `min`** fix the top and the bottom instead of taking them from
489
+ the values, e.g. to draw graphs on one scale. Values beyond them draw full
490
+ bars, or nothing on a side where no bars grow (above a `'max'` baseline,
491
+ below a `'min'` one). A numeric `baseline` must be between them; the
492
+ default `0` moves inside them, so `min: 480` alone starts the bars at 480.
493
+
494
+ **`axis`** adds a row under the bars. Strings are shown as given, numbers as
495
+ plain numbers, or as time when `unit` is set (`'h' 'm' 's' 'ms' 'µs' 'ns'`,
496
+ formatted by [`@mmutils/time`](https://www.npmjs.com/package/@mmutils/time)):
497
+
498
+ ```ts
499
+ { start: 'Mon', end: 'Fri' } // Mon Fri
500
+ { start: 500, end: 1_000_500, unit: 'ns' } // 500ns 1ms 500ns
501
+ { start: 0, end: 1032.4, unit: 'ms', capTo: 1 } // 0ms 1s 32ms
502
+ ```
503
+
504
+ A zero label takes the smallest unit of the other label, so both show the
505
+ same precision. `capTo` is that precision: a unit, or the number of units
506
+ below the largest one (default `2`: `1s 32ms 400µs`).
507
+
508
+ **`limits`** caps the size settings, because output grows with
509
+ `cols × rows`. Plugin options above the limits throw when the plugin is
510
+ created. Per-graph settings are clamped and a dim note is printed under the
511
+ graph, e.g. `(limited: cols 100000 → 500)`. `width` is limited to
512
+ `limits.cols + 2`. The limits are plugin options only, so logged data can't
513
+ raise them:
514
+
515
+ ```ts
516
+ graphPlugin({ limits: { cols: 500, rows: 50, precision: 20 } }); // defaults
517
+ ```
518
+
519
+ Per-graph settings are treated like logged data, so a graph never throws
520
+ because of them:
521
+
522
+ - `title`, `unit` and string axis labels are cut to `limits.stringLength`
523
+ (default 10,000), like message text. Other values are described
524
+ (`5`, `{"a":1}`), and `null` gives an empty label.
525
+ - An invalid `baseline`, `max` or `min` falls back to the plugin's, with a
526
+ dim note: `(limited: baseline NaN → min)`, or `(limited: max "x"
527
+ ignored)` if the plugin has none. A `max` below a numeric baseline is
528
+ raised to it and a `min` above it lowered to it, also when the plugin set
529
+ them: `(limited: max 100 → 150)`; without a numeric baseline, a `min`
530
+ above `max` is lowered to it. On the plugin, all of these throw.
531
+ - Settings that can't be read (a throwing getter, a revoked proxy) give the
532
+ graph with the plugin's settings and a dim note, e.g.
533
+ `(settings unreadable: Error: …)`.
534
+
535
+ ## Columns plugin
536
+
537
+ `columnsPlugin()` lays out messages and graphs side by side. Each log is a
538
+ cell of a grid; `column()` picks the column the next logs go to:
539
+
540
+ <!-- code:columns -->
541
+
542
+ ```ts
543
+ const logger = new Logger({ timestamp: 'HH:mm:ss' }) // short, for narrow columns
544
+ .use(messagePlugin())
545
+ .use(graphPlugin({ width: 40, rows: 2 })) // graphs exactly as wide as a column
546
+ .use(columnsPlugin({ columns: 2, width: 40 }));
547
+
548
+ for (const column of [0, 1]) {
549
+ logger.column(column);
550
+ logger.info(
551
+ column === 0 ?
552
+ 'call for call: this message wraps at the column width'
553
+ : 'same row'
554
+ );
555
+ logger.infoGraph(values);
556
+ logger.info('next row');
557
+ }
558
+ logger.endColumns();
559
+ ```
560
+
561
+ <!-- /code -->
562
+
563
+ ![Two columns side by side: wrapped messages and graphs, call for call](assets/columns.svg)
564
+
565
+ <details>
566
+ <summary><sub>The output above as plain text</sub></summary>
567
+
568
+ <!-- output:columns -->
569
+
570
+ ```text
571
+ 15:35:00 [i] info call for call: th | 15:35:00 [i] info same row
572
+ is message wraps at the column width |
573
+ 15:35:00 [i] info | 15:35:00 [i] info
574
+ 9.00 | 9.00
575
+ │ ▁▁▂▃▄▄▅▆▆██▆▆▅▄▄▃▂▁▁ │ | │ ▁▁▂▃▄▄▅▆▆██▆▆▅▄▄▃▂▁▁ │
576
+ │▂▂▃▄▄▅▆▇▇████████████████████▇▇▆▅▄▄▃▂▂│ | │▂▂▃▄▄▅▆▇▇████████████████████▇▇▆▅▄▄▃▂▂│
577
+ average: 4.76 0.00 | average: 4.76 0.00
578
+ min: 1.00 | min: 1.00
579
+ max: 9.00 | max: 9.00
580
+ 15:35:00 [i] info next row | 15:35:00 [i] info next row
581
+ ```
582
+
583
+ <!-- /output -->
584
+
585
+ <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>
586
+
587
+ </details>
588
+
589
+ ---
590
+
591
+ - **Call for call.** The n-th log of each column shares a row. A row is as
592
+ tall as its tallest cell (a wrapped message, an object, an error, a
593
+ graph); shorter cells get blank lines, so the next calls start on the same
594
+ line.
595
+ - **Fixed width.** Every line is exactly `width` terminal columns, padded
596
+ with spaces except in the last column. Colors are closed before the frame
597
+ and reopened on the next line, East Asian wide characters and most emoji
598
+ count as two columns (emoji sequences such as flags are approximate), tabs
599
+ become spaces. Other control characters are shown as `\xHH`, even in text
600
+ logged with `sanitize: 'none'`: carriage returns, cursor moves and screen
601
+ clears can't be laid out.
602
+ - **Wrapping, per kind.** `wrap` decides what happens to longer lines:
603
+ `'hard'` wraps at exactly the width, even mid-word; `'word'` wraps at the
604
+ last space that fits (longer words wrap hard); `'truncate'` cuts the line
605
+ with a dim `…` in the last column. The default is
606
+ `{ '*': 'hard', graph: 'truncate' }`: a graph wider than its column is cut,
607
+ a visible hint to size it with `graphPlugin({ width })`, instead of
608
+ leaking its bars into the next lines.
609
+
610
+ ```ts
611
+ columnsPlugin({ wrap: 'word' }); // all kinds, graphs stay cut
612
+ columnsPlugin({ wrap: { message: 'word', '*': 'truncate' } });
613
+ columnsPlugin({ kinds: ['message', 'cpu'], wrap: { cpu: 'truncate' } }); // graphPlugin({ kind: 'cpu' })
614
+ ```
615
+
616
+ Kinds of any plugin are set by their kind name; a mode on its own sets
617
+ `'*'`. `indent: 2` starts lines created by wrapping with 2 spaces, so a
618
+ continuation doesn't look like a new log; the cell's own lines (object
619
+ keys, stack frames, graph rows) are not indented. Lines that fit cost the
620
+ same in every mode; word wrap adds about 15% to a wrapped line.
621
+
622
+ - **Moving around.** `column()` without an index moves to the next column;
623
+ after the last it writes the grid and starts a new one. Each column
624
+ remembers its own next row, so switching back continues where it stopped.
625
+ `row(n)` sends the next log to row `n` of the active column, replacing
626
+ that cell; `row()` leaves the cell at the cursor empty. A row is a row of
627
+ cells, not a terminal line.
628
+ - **Streaming.** A row is written as soon as every column has moved past it,
629
+ so memory stays small. A column that stops logging holds back at most
630
+ 1,000 rows: older rows are then written as they are, their empty cells
631
+ blank. A log for a row that was already written goes to the column's next
632
+ free row. `endColumns()` writes the rest, empty cells
633
+ blank, and returns to normal output.
634
+ - **Live.** With `live: true` every log redraws the rows that can still
635
+ change, so the grid fills on screen as the calls come in and `row(n)`
636
+ updates a cell in place (progress, status boards). It moves the terminal
637
+ cursor up over its own lines, so only rows still on the screen can change;
638
+ older rows stay as they are. Logs of other kinds (a second message kind,
639
+ JSON lines) leave the drawn rows first; writes the logger doesn't see
640
+ (`console.log`, `stderr`) would be overwritten, and so would the screen
641
+ after a terminal resize. A `write` that logs to the same logger throws:
642
+ it would draw over the lines being drawn. Without a terminal (piped
643
+ or redirected output) or when the grid is wider than the terminal, it
644
+ falls back to streaming.
645
+
646
+ ```ts
647
+ columnsPlugin({
648
+ columns: 2, // 1 to 100
649
+ width: 40, // per column, frame excluded; default: the terminal width
650
+ // (80 when unknown) split evenly, read when the plugin is created
651
+ frame: ' | ', // text between columns, no control characters
652
+ frameStyle: 'textDim', // style name of the frame
653
+ kinds: ['message', 'graph'], // kinds laid out in columns
654
+ wrap: { '*': 'hard', graph: 'truncate' }, // DEFAULT_COLUMNS_WRAP, see above
655
+ indent: 0, // spaces before lines created by wrapping
656
+ live: false, // redraw the rows on screen, see above
657
+ write: console.log, // line destination
658
+ });
659
+ ```
660
+
661
+ Use it after the plugins that render its kinds. Columns are a layout for
662
+ text kinds: JSON lines are never laid out, and `column()` throws when no
663
+ plugin renders the kinds. Logs filtered by level are not cells. Outside
664
+ `column()` … `endColumns()` the logger is not slowed down: the plugin swaps
665
+ the kinds' outputs only while a grid is open (set your own outputs before
666
+ or after, not during). Buffered rows are lost if the process exits before
667
+ `endColumns()`.
668
+
669
+ Limits keep one log from blocking or crashing the process, since a row's
670
+ output is its height times the grid's width: the grid (columns, widths and
671
+ frames) is at most 10,000 terminal columns wide (larger options throw), and
672
+ a cell at most 1,000 lines tall, the last one replaced by a dim
673
+ `… cut at 1000 lines`.
674
+
675
+ ## JSON plugin
676
+
677
+ `jsonPlugin()` writes one JSON object per log (NDJSON), for log files and
678
+ collectors, and becomes the default kind. Use it **instead of**
679
+ `messagePlugin`, not next to it: both render the whole log, and a logger has
680
+ one output style (e.g. JSON in production, `messagePlugin` in development).
681
+ If both set the default kind, the last one used wins:
682
+
683
+ <!-- code:json -->
684
+
685
+ ```ts
686
+ import { Logger, jsonPlugin } from '@mmutils/logger';
687
+
688
+ const logger = new Logger({ plugins: [jsonPlugin()] });
689
+ const err = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), {
690
+ code: 'ECONNREFUSED',
691
+ });
692
+
693
+ logger.info({ msg: 'Server started', port: 8080 });
694
+ logger.error({ msg: 'Query failed', err });
695
+ ```
696
+
697
+ <!-- /code -->
698
+
699
+ <!-- output:json -->
700
+
701
+ ```text
702
+ {"id":1,"time":"2026-03-16T15:35:00.000Z","type":"info","data":{"msg":"Server started","port":8080}}
703
+ {"id":2,"time":"2026-03-16T15:35:00.000Z","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 main (file:///app/server.js:12:16)","code":"ECONNREFUSED"}}}
704
+ ```
705
+
706
+ <!-- /output -->
707
+
708
+ ```ts
709
+ jsonPlugin({
710
+ kind: 'json', // kind written by the plugin
711
+ setAsDefault: true, // make `kind` the logger's default kind
712
+ time: 'iso', // 'iso' | 'epoch' | false
713
+ limits: { items: 100, stringLength: 10_000 }, // output size, see Limits
714
+ escapeControls: false, // escape DEL and C1 controls too
715
+ writeKind: false, // write "kind" after "type"
716
+ write: console.log, // line destination
717
+ });
718
+ ```
719
+
720
+ **Line shape.** The entry's properties in order: `id`, `time`, `type`,
721
+ `data`, then any property added by your transformers. `kind` is left out by
722
+ default: with one destination per kind it's always the same. Set
723
+ `writeKind: true` when several JSON kinds share a destination (e.g. all logs
724
+ to stdout in a container), so readers can tell them apart:
725
+
726
+ ```ts
727
+ const logger = new Logger()
728
+ .use(jsonPlugin({ writeKind: true }))
729
+ .use(jsonPlugin({ kind: 'audit', setAsDefault: false, writeKind: true }));
730
+ logger.info({ msg: 'user deleted' }, 'audit');
731
+ // {"id":1,"time":…,"type":"info","kind":"audit","data":{"msg":"user deleted"}}
732
+ ```
733
+
734
+ `data` is the logged value
735
+ as is (text, object, array, error, …), so its keys can never collide with
736
+ `id`, `time` or `type`. Undefined data is left out, like `JSON.stringify`
737
+ does.
738
+
739
+ **`time`** comes from the logger's `clock`, read once per log and shared with
740
+ the text timestamp. Its type depends only on the option, never on the clock,
741
+ and the `'precise'` clock's nanoseconds are kept:
742
+
743
+ | `time` | `'wall'` clock | `'precise'` clock |
744
+ | ----------------- | ----------------------------------- | ----------------------------------------- |
745
+ | `'iso'` (default) | `"time":"2026-03-16T15:35:00.042Z"` | `"time":"2026-03-16T15:35:00.042137508Z"` |
746
+ | `'epoch'` | `"time":1773675300042` | `"time":1773675300042,"ns":137508` |
747
+ | `false` | no time | no time |
748
+
749
+ With the logger's `timestamp: false` lines have no time and the clock isn't
750
+ read.
751
+
752
+ **Errors** keep what `JSON.stringify` drops (it writes `{}` for an error):
753
+ `name`, `message`, `stack`, own enumerable properties such as `code`, and
754
+ `cause`, up to 5 causes deep (a cause that loops back is `"[Circular]"`).
755
+ This works for errors from other realms (`vm`, workers) too.
756
+
757
+ Errors are detected **one level deep only**: the logged data itself
758
+ (`logger.error(err)`) and its direct properties (`logger.error({ err })`).
759
+ This is a performance decision: ordinary data costs a single native
760
+ `JSON.stringify`, and finding errors anywhere would mean walking every value
761
+ of every log. Errors nested deeper are left to native JSON, which keeps only
762
+ their own enumerable properties, so their message and stack are lost:
763
+
764
+ ```ts
765
+ logger.error({ err }); // {"err":{"name":"Error","message":"…","stack":"…","code":"ECONNREFUSED"}}
766
+ logger.error({ request: { err } }); // {"request":{"err":{"code":"ECONNREFUSED"}}}
767
+ ```
768
+
769
+ Put the errors you log at the top or under a direct property.
770
+
771
+ **Values JSON can't represent** are written as JSON that keeps their meaning,
772
+ never as invalid JSON or source code:
773
+
774
+ | Value | Written as |
775
+ | ---------------------------- | -------------------------------------------- |
776
+ | bigint | a JSON number with every digit |
777
+ | function, class | `"[Function: name]"`, `"[class Name]"` |
778
+ | symbol | `"Symbol(description)"` |
779
+ | buffer, typed array | `"<Buffer 68 69>"`, `"Uint8Array(2) [1, 2]"` |
780
+ | map, set, regular expression | `"Map(1) {\"a\" => 1}"`, `"/x/g"` |
781
+ | throwing getter | `"[Getter threw: Error: …]"` |
782
+ | circular, revoked proxy | `"[Unserializable: TypeError: …]"` |
783
+
784
+ Logging never throws because of the data, and every line is valid JSON; the
785
+ other properties still render. Errors of `write` propagate.
786
+
787
+ **`limits`** keep lines bounded and valid JSON, and **keep each value's
788
+ type**, so collectors that map fields by type (e.g. Elasticsearch, which
789
+ rejects a document whose field changes from array to string) accept the
790
+ line:
791
+
792
+ - strings longer than `stringLength` are cut: `"xxx… 90 more characters"`;
793
+ - arrays and objects with more than `items` elements or keys keep the first
794
+ `items`, followed by a `"… 3 more items"` element or a
795
+ `"…": "… 3 more items"` property. This applies to the data and its direct
796
+ properties (one level deep, like errors); deeper objects are written by
797
+ native JSON;
798
+ - an array or object whose JSON is longer than `stringLength` is cut
799
+ structurally: elements or properties are kept while they fit (about
800
+ `stringLength` characters, plus the note), then the same "more" note. A
801
+ first element that alone doesn't fit is cut the same way, up to 4 levels
802
+ deep, then becomes a cut string;
803
+ - an element that can't be serialized becomes a placeholder and the rest
804
+ keeps its type; a value that contains itself is `"[Circular]"`.
805
+
806
+ Cutting costs time in proportion to `stringLength`, not to the value, and
807
+ ordinary data never reaches it.
808
+
809
+ Ordinary data costs one native `JSON.stringify` after a quick check of its
810
+ values; only data with errors, binary data, bigints, functions, long strings
811
+ or arrays is serialized property by property.
812
+
813
+ **`escapeControls`**: `JSON.stringify` already escapes newlines, ESC and the
814
+ other C0 controls, so logged text can't start a new line or send a terminal
815
+ escape sequence. It writes DEL and C1 controls (`U+007F`–`U+009F`) as they
816
+ are. They're harmless for log collectors, but some terminals act on C1
817
+ controls when lines are shown raw (`cat`, `tail -f`). `escapeControls: true`
818
+ writes them as `\u00XX`: the parsed JSON is the same, at about 1 ns per
819
+ character.
820
+
821
+ A JSON kind can also live next to messages, e.g. an audit trail:
822
+
823
+ ```ts
824
+ const logger = new Logger()
825
+ .use(messagePlugin())
826
+ .use(jsonPlugin({ kind: 'audit', setAsDefault: false, write: toAuditFile }));
827
+
828
+ logger.info('Server started'); // styled message
829
+ logger.info({ action: 'login', user: 'alice' }, 'audit'); // JSON line
830
+ ```
831
+
832
+ ## Styles
833
+
834
+ Styles are named escape-code strings, baked once per level. The bundled
835
+ plugins use:
836
+
837
+ | Style | Default | Used for |
838
+ | --------------- | ------------------------- | -------------------------------------------------- |
839
+ | `textDim` | gray (bright black) | date, id, symbol, units, axis labels, stack frames |
840
+ | `textNormal` | terminal default | object keys, legend labels |
841
+ | `textHighlight` | level color, bold | messages, values, graph title, bars and frame |
842
+ | `textBadge` | level color as background | the type badge |
843
+
844
+ The level colors are the terminal's 16 basic colors, so they follow its
845
+ theme: red for `fatal` (its badge is white on red), bright red for `error`,
846
+ yellow for `warning`, blue for `info`, magenta for `debug`, cyan for
847
+ `trace`, gray for `verbose`
848
+ ([`examples/levels.js`](examples/levels.js),
849
+ [`examples/styles.js`](examples/styles.js)):
850
+
851
+ ![One message per log type, from fatal to verbose, each with its symbol, badge and color](assets/levels.svg)
852
+
853
+ <details>
854
+ <summary><sub>The output above as plain text</sub></summary>
855
+
856
+ <!-- output:levels -->
857
+
858
+ ```text
859
+ 2026/03/16 15:35:00 [X] fatal Out of memory: the process exits now
860
+ 2026/03/16 15:35:00 [!] error Payment failed: card declined for order 1042
861
+ 2026/03/16 15:35:00 /!\ warning Disk 91% full on /var, cleanup starts at 95%
862
+ 2026/03/16 15:35:00 [i] info Server listening on http://localhost:8080
863
+ 2026/03/16 15:35:00 (?) debug Cache miss for user:42, loading from the database
864
+ 2026/03/16 15:35:00 /\/ trace GET /api/orders → 200 in 34 ms
865
+ 2026/03/16 15:35:00 ... verbose Pool: 3 of 10 connections in use
866
+ ```
867
+
868
+ <!-- /output -->
869
+
870
+ </details>
871
+
872
+ ---
873
+
874
+ ### Symbols
875
+
876
+ The message and graph plugins show a symbol in front of the type badge:
877
+
878
+ | Type | `fatal` | `error` | `warning` | `info` | `debug` | `trace` | `verbose` |
879
+ | ------ | ------- | ------- | --------- | ------ | ------- | ------- | --------- |
880
+ | Symbol | `[X]` | `[!]` | `/!\` | `[i]` | `(?)` | `/\/` | `...` |
881
+
882
+ They are plain ASCII, which every terminal and font draws, one column per
883
+ character. `symbol` takes symbols per type, merged over these
884
+ (`DEFAULT_SYMBOLS`) and padded with spaces to the widest one plus one, so the
885
+ badges line up. A string is one symbol for every type, used as is; `''` shows
886
+ none:
887
+
888
+ ```ts
889
+ messagePlugin({ symbol: { info: '->', debug: '#' } }); // others stay
890
+ messagePlugin({ symbol: '' }); // no symbols, e.g. in narrow columns
891
+ ```
892
+
893
+ Emoji work too, but some fonts don't have them and terminals measure some of
894
+ them differently (newer emoji such as 🪰, and symbols followed by an emoji
895
+ variation selector such as `⚠️`): a terminal that measures one differently
896
+ from @mmutils/logger shifts that badge by a column.
897
+
898
+ ### Changing styles
899
+
900
+ Override them per level, or for all levels with `'*'`. Values are strings or
901
+ [`style-escape-codes`](https://www.npmjs.com/package/style-escape-codes)
902
+ chains:
903
+
904
+ ```ts
905
+ import { sec } from 'style-escape-codes';
906
+
907
+ messagePlugin({
908
+ styles: {
909
+ '*': { textDim: sec.dim },
910
+ error: { textBadge: sec.b.bgRed.fgWhite },
911
+ },
912
+ });
913
+ ```
914
+
915
+ Within one style sheet, level groups override `'*'`. `reset` is built in.
916
+ A plugin's `styles` are merged into its default sheet, so `'*'` there can't
917
+ replace a style the defaults set per level (`textHighlight`, `textBadge`):
918
+ set those per level, or for all levels in a later sheet.
919
+
920
+ Style names are shared by all plugins of a logger, and later sheets override
921
+ earlier ones. Each bundled plugin registers the default styles, so overrides
922
+ passed to `messagePlugin` are undone by a `graphPlugin()` used after it. To
923
+ restyle a logger with several plugins, add a sheet after them:
924
+
925
+ ```ts
926
+ const logger = new Logger({ plugins: [messagePlugin(), graphPlugin()] });
927
+ logger.addStyles({ '*': { textDim: sec.dim } }); // all plugins, all levels
928
+ logger.addStyles({ '*': { textBadge: sec.invert } }); // overrides per-level defaults too
929
+ ```
930
+
931
+ `useStyles: false` on the logger bakes every style to an empty string, e.g.
932
+ for log files or `useStyles: !process.env.NO_COLOR`; it costs nothing per
933
+ log.
934
+
935
+ ## Writing a plugin
936
+
937
+ A plugin is an object. Usually it is returned by a factory that takes the
938
+ plugin's own typed options, so no global configuration is needed.
939
+
940
+ ```ts
941
+ import type { LoggerPlugin, LogType } from '@mmutils/logger';
942
+
943
+ interface CsvMethods {
944
+ /** Logs `values` as one CSV row. */
945
+ csv(values: readonly unknown[], type?: LogType): void;
946
+ }
947
+
948
+ export const csvPlugin = (): LoggerPlugin<CsvMethods> => ({
949
+ name: 'csv',
950
+ templateMaps: {
951
+ // per-kind templates, applied to each entry property
952
+ csv: { data: (value) => (value as unknown[]).map(String).join(',') },
953
+ },
954
+ outputs: {
955
+ // per-kind output; ctx.time: time of the log (whole epoch ms), ctx.timeNs:
956
+ // nanoseconds within it with the precise clock
957
+ csv: (entry, ctx) =>
958
+ process.stdout.write(`${ctx.time ?? ''},${entry.type},${entry.data}\n`),
959
+ },
960
+ methods: (logger) => ({
961
+ csv: (values, type = 'info') => logger.log(type, values, 'csv'),
962
+ }),
963
+ });
964
+ ```
965
+
966
+ A plugin can provide:
967
+
968
+ - `styles`: style sheets available to all templates.
969
+ - `templateMaps`: functions keyed by entry property, per kind.
970
+ - `transformers`: pipeline steps `(entry, ctx) => entry`, with an optional
971
+ position. Copy keys from data with a spread (`{ ...entry, ...fields }`),
972
+ not with `Object.assign` or `entry[key] = value`: those treat a `__proto__`
973
+ key (e.g. from parsed JSON) as the entry's prototype, whose keys would then
974
+ be printed.
975
+ - `outputs`: the final destination, per kind.
976
+ - `methods`: returns a plain object of methods added to the logger, typed
977
+ through `LoggerPlugin<M>`. Class instances are rejected: their prototype
978
+ methods would not be copied.
979
+ - `defaultKind`: makes the plugin's kind the default.
980
+
981
+ Registration is atomic: every part is checked first (functions are
982
+ functions, styles convert to strings, methods override nothing, including
983
+ symbol keys), so a plugin that throws leaves the logger unchanged and can be
984
+ fixed and used again.
985
+
986
+ Re-declaring an existing logger method in `M` replaces its signature and docs
987
+ in autocomplete. That's how `messagePlugin` changes `info(data: unknown)` to
988
+ `info(message: string)`.
989
+
990
+ ## Examples
991
+
992
+ Each file in [`examples/`](examples) shows one topic, with comments on what
993
+ to look for in its output. Build first, then run one, or all of them with
994
+ `npm run demo`:
995
+
996
+ ```sh
997
+ npm run build
998
+ node examples/graphs.js
999
+ ```
1000
+
1001
+ | File | Shows |
1002
+ | ------------------------------------------------- | ------------------------------------------------------------ |
1003
+ | [`quick-start.js`](examples/quick-start.js) | The quick start above |
1004
+ | [`levels.js`](examples/levels.js) | The seven log types, filtering with `level` |
1005
+ | [`messages.js`](examples/messages.js) | Text, objects, arrays, errors, values JSON can't represent |
1006
+ | [`timestamps.js`](examples/timestamps.js) | Patterns, UTC, the precise clock, elapsed time, a fixed time |
1007
+ | [`graphs.js`](examples/graphs.js) | Size, `aggregate`, axis labels, statistics |
1008
+ | [`graph-baseline.js`](examples/graph-baseline.js) | `baseline`, `min` and `max` on realistic data |
1009
+ | [`columns.js`](examples/columns.js) | Call for call, wrap modes, graph widths, `row(n)` |
1010
+ | [`live.js`](examples/live.js) | A status board redrawn in place (run it in a terminal) |
1011
+ | [`json.js`](examples/json.js) | JSON lines, time formats, limits, an audit kind |
1012
+ | [`safety.js`](examples/safety.js) | Sanitizing, output limits, data that can't be read |
1013
+ | [`styles.js`](examples/styles.js) | Level colors, symbols, overrides, `useStyles: false` |
1014
+ | [`custom-plugin.js`](examples/custom-plugin.js) | A new kind with a method, a transformer |
1015
+
1016
+ The images in this README are drawn from the same files
1017
+ (`npm run images`), so they show what the examples print.
1018
+
1019
+ ## Development
1020
+
1021
+ ```sh
1022
+ npm install
1023
+ npm run check # format, lint, typecheck, tests
1024
+ npm run build # dist/esm, dist/cjs, dist/types
1025
+ npm run demo # every example, one after the other (after build)
1026
+ npm run images # README images, their text and code, from examples/ (after build)
1027
+ ```
1028
+
1029
+ ## AI use
1030
+
1031
+ @mmutils/logger is developed with AI assistance: most of the code, tests,
1032
+ benchmarks and documentation was written with AI. The author designs the
1033
+ features, makes the decisions and reviews the changes, and every change
1034
+ passes the same checks (formatting, lint, strict type checking and tests)
1035
+ before it is committed.
1036
+
1037
+ ## License
1038
+
1039
+ [MIT](LICENSE)