@warlock.js/logger 5.30.0 → 5.31.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -5
  2. package/README.md +8 -11
  3. package/cjs/index.cjs +336 -28
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/channels/console-log.d.mts.map +1 -1
  6. package/esm/channels/console-log.mjs.map +1 -1
  7. package/esm/channels/file-log.d.mts +128 -12
  8. package/esm/channels/file-log.d.mts.map +1 -1
  9. package/esm/channels/file-log.mjs +203 -22
  10. package/esm/channels/file-log.mjs.map +1 -1
  11. package/esm/channels/file-name-pattern.d.mts +16 -0
  12. package/esm/channels/file-name-pattern.d.mts.map +1 -0
  13. package/esm/channels/file-name-pattern.mjs +41 -0
  14. package/esm/channels/file-name-pattern.mjs.map +1 -0
  15. package/esm/channels/index.mjs +1 -1
  16. package/esm/channels/json-file-log.d.mts.map +1 -1
  17. package/esm/channels/json-file-log.mjs +24 -5
  18. package/esm/channels/json-file-log.mjs.map +1 -1
  19. package/esm/channels/sentry-log.d.mts.map +1 -1
  20. package/esm/channels/sentry-log.mjs.map +1 -1
  21. package/esm/index.d.mts +5 -3
  22. package/esm/index.mjs +3 -2
  23. package/esm/log-channel.d.mts +23 -1
  24. package/esm/log-channel.d.mts.map +1 -1
  25. package/esm/log-channel.mjs +22 -0
  26. package/esm/log-channel.mjs.map +1 -1
  27. package/esm/logger.d.mts +5 -0
  28. package/esm/logger.d.mts.map +1 -1
  29. package/esm/logger.mjs +11 -2
  30. package/esm/logger.mjs.map +1 -1
  31. package/esm/redact/escape-log-value.d.mts +26 -0
  32. package/esm/redact/escape-log-value.d.mts.map +1 -0
  33. package/esm/redact/escape-log-value.mjs +37 -0
  34. package/esm/redact/escape-log-value.mjs.map +1 -0
  35. package/esm/redact/index.mjs +1 -0
  36. package/esm/redact/redact.d.mts.map +1 -1
  37. package/esm/redact/redact.mjs.map +1 -1
  38. package/esm/types.d.mts +3 -2
  39. package/esm/types.d.mts.map +1 -1
  40. package/llms-full.txt +241 -141
  41. package/llms.txt +2 -2
  42. package/package.json +2 -2
  43. package/skills/capture-unhandled-errors/SKILL.md +9 -10
  44. package/skills/configure-logger/SKILL.md +7 -10
  45. package/skills/filter-log-entries/SKILL.md +2 -2
  46. package/skills/flush-logs-on-shutdown/SKILL.md +32 -13
  47. package/skills/index.md +4 -1
  48. package/skills/logger-basics/SKILL.md +18 -21
  49. package/skills/overview/SKILL.md +17 -6
  50. package/skills/pick-log-channel/SKILL.md +36 -24
  51. package/skills/redact-sensitive-log-fields/SKILL.md +48 -18
  52. package/skills/ship-logs-to-sentry/SKILL.md +18 -18
  53. package/skills/test-logging-code/SKILL.md +4 -2
  54. package/skills/write-custom-log-channel/SKILL.md +50 -17
package/llms-full.txt CHANGED
@@ -24,6 +24,7 @@ captureAnyUnhandledRejection();
24
24
  ```
25
25
 
26
26
  Registers:
27
+
27
28
  - `process.on("unhandledRejection", reason => log.error("app", "unhandledRejection", reason))` — logged; the process is kept alive.
28
29
  - `process.on("uncaughtException", error => log.fatal("app", "uncaughtException", error))` — logged, then `process.exit(1)` (by default).
29
30
 
@@ -31,7 +32,7 @@ The split is intentional: an `uncaughtException` leaves the process in an undefi
31
32
 
32
33
  ## Why it exits (and why that matters)
33
34
 
34
- Registering *any* `uncaughtException` listener **suppresses** Node's default "print the stack + exit non-zero." A listener that only logs therefore turns an unrecoverable crash into a silent `exit 0` — which is exactly how a config file that throws at boot can look like "the server started, then just stopped," with no error printed. So the handler restores the contract:
35
+ Registering _any_ `uncaughtException` listener **suppresses** Node's default "print the stack + exit non-zero." A listener that only logs therefore turns an unrecoverable crash into a silent `exit 0` — which is exactly how a config file that throws at boot can look like "the server started, then just stopped," with no error printed. So the handler restores the contract:
35
36
 
36
37
  - **Exits non-zero** after logging (`process.exit(1)`), following a best-effort, time-bounded `log.flush()` so buffered `FileLog` / `SentryLog` entries drain first. Opt out with `captureAnyUnhandledRejection({ exitOnUncaughtException: false })` where the process is expected to recover on its own (a dev server reloading via HMR).
37
38
  - **Falls back to `console.error`** when no terminal channel is configured yet — the early-boot window, before `log.configure(...)`, where `log.fatal` has nowhere visible to go. When a `ConsoleLog` is present it already prints the entry, so the fallback is skipped (no double output).
@@ -43,16 +44,11 @@ Registering *any* `uncaughtException` listener **suppresses** Node's default "pr
43
44
  **Once**, at startup, **after** channels are registered. Typical place: immediately after your `log.configure({...})` call.
44
45
 
45
46
  ```ts title="src/index.ts"
46
- import {
47
- log,
48
- ConsoleLog,
49
- FileLog,
50
- captureAnyUnhandledRejection,
51
- } from "@warlock.js/logger";
47
+ import { log, ConsoleLog, FileLog, captureAnyUnhandledRejection } from "@warlock.js/logger";
52
48
 
53
49
  log.configure({
54
50
  channels: [new ConsoleLog(), new FileLog({ levels: ["error"] })],
55
- autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"], // ← important; see below
51
+ autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"], // ← important; see below
56
52
  });
57
53
 
58
54
  captureAnyUnhandledRejection();
@@ -62,7 +58,7 @@ captureAnyUnhandledRejection();
62
58
 
63
59
  The `uncaughtException` handler runs a best-effort, time-bounded `log.flush()` **before** its own `process.exit(1)`, so buffered `FileLog` / `SentryLog` entries drain even though `process.exit()` skips `beforeExit`. You don't need `autoFlushOn: ["beforeExit"]` for the fatal entry to survive — the handler already drains.
64
60
 
65
- `"beforeExit"` in `autoFlushOn` is still worth setting for the *other* exit routes (a natural drain when the event loop empties on its own). For signal-driven shutdown (`SIGINT` / `SIGTERM`), include those signals in `autoFlushOn`. See the `flush-logs-on-shutdown` topic.
61
+ `"beforeExit"` in `autoFlushOn` is still worth setting for the _other_ exit routes (a natural drain when the event loop empties on its own). For signal-driven shutdown (`SIGINT` / `SIGTERM`), include those signals in `autoFlushOn`. See the `flush-logs-on-shutdown` topic.
66
62
 
67
63
  ## Idempotency — don't call it twice
68
64
 
@@ -87,7 +83,9 @@ import type { LoggingData } from "@warlock.js/logger";
87
83
  class Capture extends LogChannel {
88
84
  public name = "capture";
89
85
  public received: LoggingData[] = [];
90
- public log(data: LoggingData) { this.received.push({ ...data }); }
86
+ public log(data: LoggingData) {
87
+ this.received.push({ ...data });
88
+ }
91
89
  }
92
90
 
93
91
  it("routes unhandled rejections to the logger", async () => {
@@ -112,6 +110,7 @@ Testing the **`uncaughtException`** path additionally trips `process.exit(1)`, s
112
110
  ## Module + action the capture uses
113
111
 
114
112
  Both listeners log with:
113
+
115
114
  - `module: "app"`
116
115
  - `action: "unhandledRejection"` (at `error`) or `action: "uncaughtException"` (at `fatal`)
117
116
  - `message`: the rejection reason / exception (keep it as the raw `Error` object — file channels capture the stack).
@@ -132,10 +131,10 @@ The logger is a singleton. Do all setup in one place, as early in the app entry
132
131
 
133
132
  ## The three channel-registration methods
134
133
 
135
- | Method | Semantics |
136
- |---|---|
137
- | `log.addChannel(channel)` | **Appends.** Safe to call multiple times. |
138
- | `log.setChannels([...])` | **Replaces** the full list. |
134
+ | Method | Semantics |
135
+ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
136
+ | `log.addChannel(channel)` | **Appends.** Safe to call multiple times. |
137
+ | `log.setChannels([...])` | **Replaces** the full list. |
139
138
  | `log.configure({ channels, autoFlushOn, redact, minLevel })` | **Replaces** channels if provided; installs auto-flush if provided; sets redact / minLevel if provided. All four are optional. |
140
139
 
141
140
  All three return `this` — chainable.
@@ -154,7 +153,7 @@ if (process.env.NODE_ENV === "production") {
154
153
  autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
155
154
  });
156
155
  } else if (process.env.NODE_ENV === "test") {
157
- log.setChannels([]); // silence logger during tests
156
+ log.setChannels([]); // silence logger during tests
158
157
  } else {
159
158
  log.setChannels([new ConsoleLog()]);
160
159
  }
@@ -163,7 +162,7 @@ if (process.env.NODE_ENV === "production") {
163
162
  Import it once at the top of `src/index.ts`:
164
163
 
165
164
  ```ts title="src/index.ts"
166
- import "./logger"; // side-effect: configures singleton
165
+ import "./logger"; // side-effect: configures singleton
167
166
  import { log } from "@warlock.js/logger";
168
167
 
169
168
  log.info("app", "start", "Server listening on :3000");
@@ -210,10 +209,7 @@ Every `new Logger()` gets a unique `id` (string, prefixed `"logger-"`).
210
209
 
211
210
  ```ts
212
211
  log.configure({
213
- channels: [
214
- new ConsoleLog({ showContext: true }),
215
- new FileLog({ chunk: "daily" }),
216
- ],
212
+ channels: [new ConsoleLog({ showContext: true }), new FileLog({ chunk: "daily" })],
217
213
  autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
218
214
  redact: { paths: ["context.password", "context.headers.authorization"] },
219
215
  minLevel: process.env.LOG_LEVEL === "debug" ? "debug" : "info",
@@ -270,7 +266,7 @@ For the common "drop everything below X" case, skip the per-channel `levels` arr
270
266
  log.setMinLevel("info");
271
267
  // debug entries are dropped before fan-out — no channel ever sees them.
272
268
 
273
- log.configure({ minLevel: "warn" }); // shorthand inside configure()
269
+ log.configure({ minLevel: "warn" }); // shorthand inside configure()
274
270
  ```
275
271
 
276
272
  Severity ordering: `debug < info ≈ success < warn < error < fatal`. `success` is treated as informational severity — `setMinLevel("warn")` drops it. `fatal` is strictly above `error`, so `setMinLevel("fatal")` admits only fatal entries (handy for "page me only on fatal" routing).
@@ -278,7 +274,7 @@ Severity ordering: `debug < info ≈ success < warn < error < fatal`. `success`
278
274
  Pass `undefined` to clear:
279
275
 
280
276
  ```ts
281
- log.setMinLevel(undefined); // accept everything again
277
+ log.setMinLevel(undefined); // accept everything again
282
278
  ```
283
279
 
284
280
  This runs **before** the channel loop — cheaper than per-channel `levels` filters when you want a uniform floor. Per-channel `levels` and `filter` still run on top for channels that need a tighter or differently-shaped rule.
@@ -356,7 +352,7 @@ The `minLevel` check is the fastest of the three (single comparison before fan-o
356
352
 
357
353
  ---
358
354
  name: flush-logs-on-shutdown
359
- description: 'Drain buffered channels before exit — log.flushSync() or log.configure({autoFlushOn: [''SIGINT'', ''SIGTERM'', ''beforeExit'']}) installs handlers that re-raise the signal. Triggers: `log.flush`, `log.flushSync`, `autoFlushOn`, `enableAutoFlush`, `disableAutoFlush`, `SIGINT`, `SIGTERM`, `beforeExit`; "drain logs before exit", "await log.flush() before process.exit", "drain async or network channels on shutdown", "wire SIGTERM for container shutdown", "my logs never showed after a crash", "graceful shutdown logging"; typical import `import { log, FileLog } from "@warlock.js/logger"`. Skip: error capture — the `capture-unhandled-errors` topic; custom sinks — the `write-custom-log-channel` topic; competing `pino.final`, `winston.end`; native `process.on(''exit'')`.'
355
+ description: 'Drain buffered channels before exit — log.flushSync() or log.configure({autoFlushOn: [''SIGINT'', ''SIGTERM'', ''beforeExit'']}) installs handlers that re-raise the signal. Triggers: `log.flush`, `log.flushSync`, `onChannelError`, `log.flush({ timeout })`, `autoFlushOn`, `enableAutoFlush`, `disableAutoFlush`, `SIGINT`, `SIGTERM`, `beforeExit`; "drain logs before exit", "await log.flush() before process.exit", "drain async or network channels on shutdown", "wire SIGTERM for container shutdown", "my logs never showed after a crash", "graceful shutdown logging"; typical import `import { log, FileLog } from "@warlock.js/logger"`. Skip: error capture — the `capture-unhandled-errors` topic; custom sinks — the `write-custom-log-channel` topic; competing `pino.final`, `winston.end`; native `process.on(''exit'')`.'
360
356
  ---
361
357
 
362
358
  # Lifecycle — flushing buffered channels before exit
@@ -376,14 +372,15 @@ log.configure({
376
372
 
377
373
  ### What each event does
378
374
 
379
- | Event | Behavior |
380
- |---|---|
375
+ | Event | Behavior |
376
+ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
381
377
  | `SIGINT` / `SIGTERM` / `SIGHUP` / `SIGBREAK` / `SIGUSR2` | Flush → remove this handler → re-raise the signal so Node's default exit code runs (e.g. 130 for SIGINT). |
382
- | `beforeExit` | Flush in place. Node continues its natural exit. |
378
+ | `beforeExit` | Flush in place. Node continues its natural exit. |
383
379
 
384
380
  ### Default recommendation
385
381
 
386
382
  `["SIGINT", "SIGTERM", "beforeExit"]` covers:
383
+
387
384
  - Local `Ctrl+C` (SIGINT)
388
385
  - Container orchestrators (`docker stop`, Kubernetes sending SIGTERM)
389
386
  - Natural exit (Node finished all work)
@@ -402,7 +399,7 @@ Use this when you need async work (close an HTTP server, drain a queue) **before
402
399
  async function gracefulShutdown() {
403
400
  await httpServer.close();
404
401
  await queue.drain();
405
- log.flushSync(); // still sync — guarantees disk write before exit
402
+ log.flushSync(); // still sync — guarantees disk write before exit
406
403
  process.exit(0);
407
404
  }
408
405
 
@@ -428,13 +425,13 @@ process.once("SIGTERM", gracefulShutdown);
428
425
 
429
426
  `log.flush()` drains every channel concurrently: each waits for its own async deliveries still in flight (level methods are fire-and-forget and never return a promise; entries logged during the flush are awaited too), then runs its `flush()`. A hung network delivery can't hold up the file channel, and one channel's flush rejecting neither aborts the others nor escapes as an unhandled rejection. It is bounded: `log.flush({ timeout })` resolves after `timeout` ms (default 5000) and reports each channel still draining to `onChannelError`.
430
427
 
431
- | | `flushSync()` | `flush()` |
432
- |---|---|---|
433
- | I/O | synchronous — blocks the loop | asynchronous — awaited |
434
- | Safe in a re-raising signal handler | yes | no — the signal exits before the promise settles |
435
- | Used by `autoFlushOn` | yes | no |
436
- | Reach for it when | file channels, last-resort durability | network/async channels, manual `await` before `process.exit` |
437
- | `FileLog` / `JSONFileLog` | ✓ | ✓ (async write) |
428
+ | | `flushSync()` | `flush()` |
429
+ | ----------------------------------- | ------------------------------------- | ------------------------------------------------------------ |
430
+ | I/O | synchronous — blocks the loop | asynchronous — awaited |
431
+ | Safe in a re-raising signal handler | yes | no — the signal exits before the promise settles |
432
+ | Used by `autoFlushOn` | yes | no |
433
+ | Reach for it when | file channels, last-resort durability | network/async channels, manual `await` before `process.exit` |
434
+ | `FileLog` / `JSONFileLog` | ✓ | ✓ (async write) |
438
435
 
439
436
  `autoFlushOn` always uses `flushSync()` — signal re-raising can't wait on a promise. If a channel needs async delivery on shutdown, drive `await log.flush()` from your own handler and leave that signal out of `autoFlushOn`.
440
437
 
@@ -455,7 +452,7 @@ log.flushSync();
455
452
 
456
453
  ## Unhandled errors
457
454
 
458
- The `uncaughtException` path in `captureAnyUnhandledRejection()` (the `capture-unhandled-errors` topic) already runs a best-effort, time-bounded `log.flush()` before its own `process.exit(1)`, so the fatal entry drains without extra wiring (and `process.exit()` skips `beforeExit`, so a `beforeExit` handler would not fire on that path anyway). Still set `autoFlushOn` for the *other* shutdown routes — `"SIGINT"` / `"SIGTERM"` and a natural `"beforeExit"` — so those don't lose the last buffered batch.
455
+ The `uncaughtException` path in `captureAnyUnhandledRejection()` (the `capture-unhandled-errors` topic) already runs a best-effort, time-bounded `log.flush()` before its own `process.exit(1)`, so the fatal entry drains without extra wiring (and `process.exit()` skips `beforeExit`, so a `beforeExit` handler would not fire on that path anyway). Still set `autoFlushOn` for the _other_ shutdown routes — `"SIGINT"` / `"SIGTERM"` and a natural `"beforeExit"` — so those don't lose the last buffered batch.
459
456
 
460
457
  ```ts
461
458
  log.configure({
@@ -472,6 +469,24 @@ captureAnyUnhandledRejection();
472
469
  - **Don't call `process.exit()` inside `autoFlushOn` handlers** — signal handlers here already re-raise the signal. Forcing an exit breaks exit codes.
473
470
  - **Don't rely on the 5-second flush interval for shutdown safety.** It's a throughput optimization, not a durability guarantee.
474
471
 
472
+ ## Observe channel failures and drain explicitly
473
+
474
+ ```ts
475
+ import { log } from "@warlock.js/logger";
476
+
477
+ log.onChannelError((error, channel) => {
478
+ // Do not call this logger from its own failure handler.
479
+ console.error("Log delivery failed", channel.name, error);
480
+ });
481
+ log.info("app", "shutdown", "Draining logs");
482
+ await log.flush({ timeout: 3_000 });
483
+ ```
484
+
485
+ Level methods return the logger synchronously. `flush()` waits for async
486
+ channel work within its timeout, reports failures through `onChannelError`, and
487
+ resolves so shutdown can continue. Timeout does not cancel a channel's network
488
+ operation; its driver owns cancellation.
489
+
475
490
 
476
491
  ## logger-basics `@warlock.js/logger/logger-basics/SKILL.md`
477
492
 
@@ -514,10 +529,7 @@ The 11 things that are true in every logger use:
514
529
  import { log, ConsoleLog, FileLog } from "@warlock.js/logger";
515
530
 
516
531
  log.configure({
517
- channels: [
518
- new ConsoleLog(),
519
- new FileLog({ chunk: "daily", storagePath: "./storage/logs" }),
520
- ],
532
+ channels: [new ConsoleLog(), new FileLog({ chunk: "daily", storagePath: "./storage/logs" })],
521
533
  autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
522
534
  });
523
535
 
@@ -528,12 +540,12 @@ log.error("payments", "charge", new Error("Card declined"));
528
540
  ## The six levels
529
541
 
530
542
  ```ts
531
- log.debug("module", "action", "verbose detail"); // dev-only diagnostics
532
- log.info("module", "action", "neutral event"); // user-visible event
533
- log.warn("module", "action", "something off"); // recoverable concern
534
- log.error("module", "action", error); // handled failure, app continues
535
- log.success("module", "action", "operation done"); // explicit success
536
- log.fatal("module", "action", error); // unrecoverable, app is going down
543
+ log.debug("module", "action", "verbose detail"); // dev-only diagnostics
544
+ log.info("module", "action", "neutral event"); // user-visible event
545
+ log.warn("module", "action", "something off"); // recoverable concern
546
+ log.error("module", "action", error); // handled failure, app continues
547
+ log.success("module", "action", "operation done"); // explicit success
548
+ log.fatal("module", "action", error); // unrecoverable, app is going down
537
549
  ```
538
550
 
539
551
  `fatal` is purely informational — it does NOT auto-flush or exit. The caller decides whether to `await log.flush()` and `process.exit(...)`. See the `capture-unhandled-errors` topic for the `uncaughtException` → `fatal` routing.
@@ -542,17 +554,17 @@ Every call signature is the same — `module`, `action`, `message`, optional `co
542
554
 
543
555
  ## Pick a skill
544
556
 
545
- | If the task is about… | Load |
546
- | --- | --- |
547
- | Picking a channel — what each built-in does, when to use which | the `pick-log-channel` topic |
548
- | Startup — registering channels, environment-based setup, the `configure` method | the `configure-logger` topic |
549
- | Filtering log output (`levels`, `filter`, per-channel routing, `minLevel`) | the `filter-log-entries` topic |
550
- | Graceful shutdown — `flushSync`, `autoFlushOn`, signal behavior | the `flush-logs-on-shutdown` topic |
551
- | Extending `LogChannel` to build a custom sink (Slack, database, HTTP) | the `write-custom-log-channel` topic |
552
- | Routing Node's `unhandledRejection` / `uncaughtException` through the logger | the `capture-unhandled-errors` topic |
553
- | `log.assert(...)` and `log.timer(...)` shorthand helpers | the `use-log-helpers` topic |
554
- | Redacting secrets — logger floor + additive channel paths | the `redact-sensitive-log-fields` topic |
555
- | Tests that assert on log output, or code under test that logs | the `test-logging-code` topic |
557
+ | If the task is about… | Load |
558
+ | ------------------------------------------------------------------------------- | --------------------------------------- |
559
+ | Picking a channel — what each built-in does, when to use which | the `pick-log-channel` topic |
560
+ | Startup — registering channels, environment-based setup, the `configure` method | the `configure-logger` topic |
561
+ | Filtering log output (`levels`, `filter`, per-channel routing, `minLevel`) | the `filter-log-entries` topic |
562
+ | Graceful shutdown — `flushSync`, `autoFlushOn`, signal behavior | the `flush-logs-on-shutdown` topic |
563
+ | Extending `LogChannel` to build a custom sink (Slack, database, HTTP) | the `write-custom-log-channel` topic |
564
+ | Routing Node's `unhandledRejection` / `uncaughtException` through the logger | the `capture-unhandled-errors` topic |
565
+ | `log.assert(...)` and `log.timer(...)` shorthand helpers | the `use-log-helpers` topic |
566
+ | Redacting secrets — logger floor + additive channel paths | the `redact-sensitive-log-fields` topic |
567
+ | Tests that assert on log output, or code under test that logs | the `test-logging-code` topic |
556
568
 
557
569
  ## Things NOT to do
558
570
 
@@ -601,52 +613,63 @@ Eleven task skills cover everything. Load the one that matches your job — most
601
613
  ### Foundations
602
614
 
603
615
  #### `logger-basics`
616
+
604
617
  Start here. The `log` singleton, the six levels (`debug` / `info` / `warn` / `error` / `success` / `fatal`), how fan-out works, the `module / action / message / context` shape every entry carries.
605
618
 
606
619
  #### `configure-logger`
620
+
607
621
  Wire channels at boot — `log.addChannel`, `log.setChannels`, `log.configure({ channels, autoFlushOn, redact, minLevel })`. Branch on `NODE_ENV`, replace the channel list, isolate a library's logger from the host singleton.
608
622
 
609
623
  ### Channels
610
624
 
611
625
  #### `pick-log-channel`
626
+
612
627
  Pick one of the four built-ins: `ConsoleLog` (terminal, colored), `FileLog` (plain `.log` on disk with rotation), `JSONFileLog` (structured JSON for aggregators — Datadog, Loki, ELK), `SentryLog` (errors + breadcrumbs to Sentry; `@sentry/node` is an optional peer).
613
628
 
614
629
  #### `ship-logs-to-sentry`
630
+
615
631
  The `SentryLog` channel in depth — event-vs-breadcrumb level mapping, dual init modes (reuse an existing client or pass `options`), draining via `Sentry.flush(timeout)`, behavior when the optional peer isn't installed.
616
632
 
617
633
  #### `write-custom-log-channel`
634
+
618
635
  Extend `LogChannel<Options>` for sinks the built-ins don't cover — Slack, HTTP endpoint, in-memory buffer, database. The lazy `init()` lifecycle (`setTimeout(0)`), the `terminal: true/false` ANSI-stripping behavior, and the difference between `flush()` (async, network) and `flushSync()` (sync, files) are subtle — read this skill before subclassing.
619
636
 
620
637
  ### Production concerns
621
638
 
622
639
  #### `redact-sensitive-log-fields`
640
+
623
641
  Strip secrets before they reach a sink. Logger-wide `setRedact({ paths, censor })` is the security floor; per-channel `redact` configs add paths (never remove). Dotted-glob paths (`*`, `**`); censor as string or function `(value, path) => any`.
624
642
 
625
643
  #### `filter-log-entries`
644
+
626
645
  Drop entries before they cost anything. Logger-wide `setMinLevel("info")` is the fast path; per-channel `levels` array + `filter` predicate for fine control.
627
646
 
628
647
  #### `flush-logs-on-shutdown`
648
+
629
649
  Buffered channels need explicit drain. `log.flushSync()` (sync) for file channels — also wired by `enableAutoFlush(['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK', 'SIGUSR2', 'beforeExit'])`. `await log.flush()` (async) for network/async channels like `SentryLog` — the only path that can await an HTTPS round-trip on a graceful shutdown.
630
650
 
631
651
  #### `capture-unhandled-errors`
652
+
632
653
  `captureAnyUnhandledRejection()` hooks `unhandledRejection` (→ `log.error("app", ...)`, process kept alive) and `uncaughtException` (→ `log.fatal("app", ...)` then `process.exit(1)` — restoring the non-zero exit the listener would otherwise suppress, so a fatal crash never becomes a silent `exit 0`; opt out with `{ exitOnUncaughtException: false }`). It flushes before exiting and falls back to `console.error` when no terminal channel is set. One call at startup.
633
654
 
634
655
  ### Ergonomics + testing
635
656
 
636
657
  #### `use-log-helpers`
658
+
637
659
  Two shortcuts every `Logger` exposes: `log.assert(condition, module, action, message, context?)` logs an error only when the condition is falsy (free on the happy path); `log.timer(module, action)` returns an end-function that emits `info` with a measured `durationMs`.
638
660
 
639
661
  #### `test-logging-code`
662
+
640
663
  Silence the logger globally in tests via `log.setChannels([])` in `setupFiles`. Assert specific entries with a capturing `LogChannel` subclass — it proves an entry was actually delivered through the pipeline (filters, redaction), not merely that a method was called, and it isolates cleanly by swapping `log.channels`.
641
664
 
642
665
  ## Built-in channels at a glance
643
666
 
644
- | Channel | Sink | `terminal` | Buffered |
645
- | --- | --- | --- | --- |
646
- | `ConsoleLog` | `process.stdout` | `true` (colors kept) | no |
647
- | `FileLog` | `.log` files | `false` (ANSI stripped) | yes (5s timer or 100-entry buffer) |
648
- | `JSONFileLog` | `.json` files | `false` (ANSI stripped) | yes (same buffering) |
649
- | `SentryLog` | Sentry events + breadcrumbs | `false` | via the Sentry SDK's own transport (`@sentry/node` is an optional peer) |
667
+ | Channel | Sink | `terminal` | Buffered |
668
+ | ------------- | --------------------------- | ----------------------- | ----------------------------------------------------------------------- |
669
+ | `ConsoleLog` | `process.stdout` | `true` (colors kept) | no |
670
+ | `FileLog` | `.log` files | `false` (ANSI stripped) | yes (5s timer or 100-entry buffer) |
671
+ | `JSONFileLog` | `.json` files | `false` (ANSI stripped) | yes (same buffering) |
672
+ | `SentryLog` | Sentry events + breadcrumbs | `false` | via the Sentry SDK's own transport (`@sentry/node` is an optional peer) |
650
673
 
651
674
  `terminal: true` is the flag that decides whether the channel sees raw colored messages or stripped plain text. Custom channels: pick `true` if you write to a terminal, `false` for anything else.
652
675
 
@@ -676,12 +699,12 @@ Four built-in channels (`SentryLog` needs the optional `@sentry/node` peer). A c
676
699
 
677
700
  ## The decision
678
701
 
679
- | Need | Pick |
680
- |---|---|
681
- | Local dev, colored output in the terminal | `ConsoleLog` |
682
- | Plain text `.log` files on disk — humans read them | `FileLog` |
702
+ | Need | Pick |
703
+ | --------------------------------------------------------------------------------- | ------------- |
704
+ | Local dev, colored output in the terminal | `ConsoleLog` |
705
+ | Plain text `.log` files on disk — humans read them | `FileLog` |
683
706
  | Structured `.json` files — a log aggregator (Loki / Datadog / Elastic) reads them | `JSONFileLog` |
684
- | Errors & warnings into Sentry (events + breadcrumbs) | `SentryLog` |
707
+ | Errors & warnings into Sentry (events + breadcrumbs) | `SentryLog` |
685
708
 
686
709
  Most production setups use **two** channels: `ConsoleLog` + one file channel. Dev uses `ConsoleLog` only.
687
710
 
@@ -699,6 +722,7 @@ new ConsoleLog();
699
722
  ```
700
723
 
701
724
  Properties:
725
+
702
726
  - `name = "console"`, `terminal = true`
703
727
  - Accepts `ConsoleLogConfig` — `levels`, `filter`, `dateFormat`, `showContext`, `contextDepth`
704
728
  - If `message` is an object, a second `console.log(message)` is issued so Node's inspector can expand it
@@ -725,14 +749,26 @@ Plain text. Buffers in memory, flushes to disk periodically.
725
749
  import { FileLog } from "@warlock.js/logger";
726
750
 
727
751
  new FileLog({
728
- storagePath: "./storage/logs", // default: process.cwd() + "/storage/logs"
729
- name: "app", // default: "app"
730
- extension: "log", // default: "log"
731
- chunk: "daily", // "single" (default) | "daily" | "hourly"
732
- rotate: true, // default: true
733
- maxFileSize: 10 * 1024 * 1024, // default: 10MB — triggers rotation
734
- maxMessagesToWrite: 100, // default: 100 — flush threshold
735
- groupBy: ["level", "module"], // optional subdirectory nesting
752
+ storagePath: "./storage/logs", // default: process.cwd() + "/storage/logs"
753
+ name: "app", // default: "app"
754
+ extension: "log", // default: "log"
755
+ chunk: "daily", // "single" (default) | "daily" | "hourly"
756
+ rotate: true, // default: true
757
+ maxFileSize: 10 * 1024 * 1024, // default: 10MB — triggers rotation
758
+ maxMessagesToWrite: 100, // default: 100 — flush threshold
759
+ groupBy: ["level", "module"], // optional subdirectory nesting
760
+ retention: { maxFiles: 14, maxAgeDays: 30 }, // optional pruning of old files
761
+ });
762
+ ```
763
+
764
+ Name dated files yourself with `fileName` — a pattern (`{YYYY}` `{MM}` `{DD}` `{HH}` `{mm}` `{name}`, local time) or a function of the date:
765
+
766
+ ```ts
767
+ new FileLog({ storagePath: "./storage/logs", fileName: "{name}-{YYYY}-{MM}-{DD}" }); // app-2026-10-17.log
768
+ new FileLog({
769
+ storagePath: "./storage/logs",
770
+ fileName: (date) => `audit-${date.getFullYear()}`,
771
+ retention: { maxFiles: 5, match: /^audit-\d{4}\.log$/ }, // a function fileName needs `match`
736
772
  });
737
773
  ```
738
774
 
@@ -741,7 +777,8 @@ Line format: `[date time] [level] [module][action]: message` — or a `[trace]`
741
777
  ### Key gotchas
742
778
 
743
779
  - **Buffers!** Messages sit in memory until either `maxMessagesToWrite` is reached, 5 seconds pass, or `flushSync()` is called. A process that crashes without flushing loses buffered entries.
744
- - **`chunk: "daily"` picks a filename per day.** File name becomes `DD-MM-YYYY.log`. Combined with `rotate: true`, rotated archives get `Date.now()` suffixed.
780
+ - **`chunk: "daily"` picks a filename per day.** File name becomes `YYYY-MM-DD.log` (hourly: `YYYY-MM-DD-HH.log`), ignoring `name` unless `fileName` uses `{name}`. Since 5.31; keep the old `DD-MM-YYYY` with `fileName: "{DD}-{MM}-{YYYY}"`. Rotated archives are `<file name>-<rotateFileName>-<epoch ms>.<ext>`; `rotateFileName` is a pattern or function too (default `{YYYY}-{MM}-{DD}`).
781
+ - **`retention` only touches this channel's files.** On boot and whenever a new file starts it removes files in the log directory (each group directory with `groupBy`) whose names match the channel's pattern — beyond `maxFiles` (newest by modification time, the current file counts) or older than `maxAgeDays`. Failures go to `log.onChannelError(...)`, never to the call site. `await channel.prune()` runs it on demand.
745
782
  - **`groupBy` nests directories.** `groupBy: ["level", "module"]` produces `storage/logs/error/payments/app.log`. Order matters.
746
783
  - **Dispose channels you discard.** A live `FileLog` keeps a 5-second flush interval running. If you swap the channel list at runtime (reconfigure the logger), call `channel.dispose()` on the old instance — it clears that timer and drains the buffer one last time. Skipping it leaks one timer per discarded channel and keeps the event loop alive. (Channels that live for the whole process don't need this — process exit clears the timer.)
747
784
 
@@ -758,16 +795,14 @@ Subclass of `FileLog` — same buffering, chunking, rotation, grouping. Output i
758
795
  "date": "15-03-2024 10:22:03",
759
796
  "module": "payments",
760
797
  "action": "charge",
761
- "stack": [
762
- "Error: Card declined",
763
- " at chargeCard (/app/src/payments.ts:42:11)"
764
- ]
798
+ "stack": ["Error: Card declined", " at chargeCard (/app/src/payments.ts:42:11)"]
765
799
  }
766
800
  ]
767
801
  }
768
802
  ```
769
803
 
770
804
  Differences from `FileLog`:
805
+
771
806
  - `name = "fileJson"` (**not** `"json"` — use this exact string for `log.channel("fileJson")`)
772
807
  - `extension` is always `"json"` — the option is silently ignored
773
808
  - Error `stack` is stored as `string[]` (split on newlines) — easy to query in aggregators
@@ -794,8 +829,8 @@ Every channel constructor accepts at minimum:
794
829
 
795
830
  ```ts
796
831
  type BasicLogConfigurations = {
797
- levels?: LogLevel[]; // whitelist — omit or [] to allow all
798
- filter?: (data: LoggingData) => boolean; // custom predicate
832
+ levels?: LogLevel[]; // whitelist — omit or [] to allow all
833
+ filter?: (data: LoggingData) => boolean; // custom predicate
799
834
  dateFormat?: { date?: string; time?: string }; // Day.js format strings
800
835
  context?: (data) => Promise<Record<string, any>>; // reserved — not yet read
801
836
  };
@@ -806,10 +841,10 @@ Concrete file channels extend this with their storage/chunk/rotate/groupBy optio
806
841
  ## Picking a channel by name at runtime
807
842
 
808
843
  ```ts
809
- log.channel("console"); // → ConsoleLog | undefined
810
- log.channel("file"); // → FileLog | undefined
811
- log.channel("fileJson"); // ← note the name — NOT "json"
812
- log.channel("sentry"); // → SentryLog | undefined
844
+ log.channel("console"); // → ConsoleLog | undefined
845
+ log.channel("file"); // → FileLog | undefined
846
+ log.channel("fileJson"); // ← note the name — NOT "json"
847
+ log.channel("sentry"); // → SentryLog | undefined
813
848
  ```
814
849
 
815
850
  If two channels share a `name`, only one is reachable this way — the search returns the first match.
@@ -826,7 +861,7 @@ If two channels share a `name`, only one is reachable this way — the search re
826
861
 
827
862
  ---
828
863
  name: redact-sensitive-log-fields
829
- description: 'Strip secrets from log output — a built-in secret-key denylist on by default (DEFAULT_REDACT_KEYS), plus two-layer additive redaction via log.configure({redact: {paths, keys}}) (logger floor) + per-channel redact (more on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `keys`, `defaultKeys`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging", "turn off default redaction"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — the `filter-log-entries` topic; custom sinks — the `write-custom-log-channel` topic; competing libs `pino.redact`, `fast-redact`.'
864
+ description: 'Strip secrets from log output — a built-in secret-key denylist on by default (DEFAULT_REDACT_KEYS), plus two-layer additive redaction via log.configure({redact: {paths, keys}}) (logger floor) + per-channel redact (more on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `keys`, `defaultKeys`, `censor`, `log.setRedact`, `applyRedact`, `maskEmail`, `maskEmailsInText`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging", "turn off default redaction"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — the `filter-log-entries` topic; custom sinks — the `write-custom-log-channel` topic; competing libs `pino.redact`, `fast-redact`.'
830
865
  ---
831
866
 
832
867
  # Redaction — keeping secrets out of logs
@@ -851,15 +886,15 @@ import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
851
886
 
852
887
  log.configure({
853
888
  redact: {
854
- keys: ["internalRef"], // union with the built-in set
855
- defaultKeys: false, // opt out of the built-in set entirely
889
+ keys: ["internalRef"], // union with the built-in set
890
+ defaultKeys: false, // opt out of the built-in set entirely
856
891
  },
857
892
  });
858
893
  ```
859
894
 
860
895
  `defaultKeys: false` is an escape hatch, not a tuning knob — it restores the pre-4.15.0 behavior where a `password` in `context` reaches every sink in cleartext. Prefer adding a function `censor` if you only need to keep a prefix.
861
896
 
862
- **A channel cannot turn the default set off** (it can only add keys, or turn it back *on* if the logger-wide config disabled it) — same additive-only contract as paths, below. And `log.setRedact(undefined)` clears *your* paths, not the default denylist.
897
+ **A channel cannot turn the default set off** (it can only add keys, or turn it back _on_ if the logger-wide config disabled it) — same additive-only contract as paths, below. And `log.setRedact(undefined)` clears _your_ paths, not the default denylist.
863
898
 
864
899
  Not reachable by any layer: secrets interpolated into a `message` string (`` `token=${t}` ``), `Map`/`Set`/`Buffer` contents, and getter-backed or non-enumerable properties.
865
900
 
@@ -880,18 +915,14 @@ import { log } from "@warlock.js/logger";
880
915
 
881
916
  log.configure({
882
917
  redact: {
883
- paths: [
884
- "context.password",
885
- "context.*.token",
886
- "context.headers.authorization",
887
- ],
888
- censor: "[REDACTED]", // default — string or function
918
+ paths: ["context.password", "context.*.token", "context.headers.authorization"],
919
+ censor: "[REDACTED]", // default — string or function
889
920
  },
890
921
  });
891
922
 
892
923
  // runtime equivalent:
893
924
  log.setRedact({ paths: ["context.password"] });
894
- log.setRedact(undefined); // clear your paths (the default denylist stays on)
925
+ log.setRedact(undefined); // clear your paths (the default denylist stays on)
895
926
  ```
896
927
 
897
928
  Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
@@ -933,13 +964,13 @@ type LoggingData = {
933
964
  };
934
965
  ```
935
966
 
936
- | Pattern | Matches |
937
- | --- | --- |
938
- | `context.password` | exactly `data.context.password` |
939
- | `context.*.token` | `data.context.<any>.token` (one segment in between) |
940
- | `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
941
- | `message.apiKey` | when message is an object, `data.message.apiKey` |
942
- | `context.users.*.token` | array element redaction (`*` matches indices too) |
967
+ | Pattern | Matches |
968
+ | ----------------------- | ------------------------------------------------------------------ |
969
+ | `context.password` | exactly `data.context.password` |
970
+ | `context.*.token` | `data.context.<any>.token` (one segment in between) |
971
+ | `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
972
+ | `message.apiKey` | when message is an object, `data.message.apiKey` |
973
+ | `context.users.*.token` | array element redaction (`*` matches indices too) |
943
974
 
944
975
  Wildcards:
945
976
 
@@ -981,6 +1012,40 @@ If `message` is a plain object, paths under `message.*` work as expected. If `me
981
1012
 
982
1013
  For most apps with `<10` redact paths and shallow context, the cost is below 100µs per entry. If you're logging millions of entries per second through paths like `**.something`, profile before scaling up — `**` is the only pattern that recurses through every key.
983
1014
 
1015
+ ## Email addresses inside strings
1016
+
1017
+ ```ts
1018
+ import { log, maskEmail, maskEmailsInText } from "@warlock.js/logger";
1019
+
1020
+ log.info("mail", "send", `Sending to ${maskEmail("member@example.com")}`);
1021
+ log.debug("cache", "key", maskEmailsInText("profile:member@example.com"));
1022
+ ```
1023
+
1024
+ Key-based redaction cannot mask a substring inside a message or cache key.
1025
+ `maskEmail()` keeps the first code point and domain (`m***@example.com`);
1026
+ `maskEmailsInText()` handles text and bounds its scan at 16,384 characters,
1027
+ replacing the remaining suffix with a truncation marker.
1028
+
1029
+ ## Client text inside a message: `escapeLogValue()`
1030
+
1031
+ A user agent or search term with a line break forges an extra entry in console
1032
+ and file logs; an ANSI sequence rewrites the terminal. Escape it first:
1033
+
1034
+ ```ts
1035
+ import { escapeLogValue, log } from "@warlock.js/logger";
1036
+
1037
+ export function logSearch(term: string, userAgent: string) {
1038
+ log.info("search", "query", `"${escapeLogValue(term, { maxLength: 200 })}"`, {
1039
+ agent: escapeLogValue(userAgent),
1040
+ });
1041
+ }
1042
+ ```
1043
+
1044
+ CR/LF/tab become `\r`/`\n`/`\t`, other control characters `\uXXXX`, ANSI
1045
+ sequences are removed; `maxLength` cuts and marks `…[truncated]`. `warlock
1046
+ check` (from `@warlock.js/core`) reports raw emails, credentials and request
1047
+ bodies in log calls as `WL9008` and accepts these helpers.
1048
+
984
1049
 
985
1050
  ## ship-logs-to-sentry `@warlock.js/logger/ship-logs-to-sentry/SKILL.md`
986
1051
 
@@ -1036,14 +1101,14 @@ With neither `client` nor `options`, the channel reuses whatever global Sentry c
1036
1101
 
1037
1102
  This is the quota-control decision. Only `eventLevels` create Sentry **events** (which consume your error quota); every other level becomes a **breadcrumb** that rides along with the next event for free.
1038
1103
 
1039
- | Logger level | Default | Sentry call |
1040
- |---|---|---|
1041
- | `fatal` | event | `captureException` for an `Error` message, else `captureMessage(…, "fatal")` |
1042
- | `error` | event | `captureException` for an `Error` message, else `captureMessage(…, "error")` |
1043
- | `warn` | event | `captureMessage(…, "warning")` |
1044
- | `success` | breadcrumb | `addBreadcrumb({ level: "info" })` |
1045
- | `info` | breadcrumb | `addBreadcrumb({ level: "info" })` |
1046
- | `debug` | breadcrumb | `addBreadcrumb({ level: "debug" })` |
1104
+ | Logger level | Default | Sentry call |
1105
+ | ------------ | ---------- | ---------------------------------------------------------------------------- |
1106
+ | `fatal` | event | `captureException` for an `Error` message, else `captureMessage(…, "fatal")` |
1107
+ | `error` | event | `captureException` for an `Error` message, else `captureMessage(…, "error")` |
1108
+ | `warn` | event | `captureMessage(…, "warning")` |
1109
+ | `success` | breadcrumb | `addBreadcrumb({ level: "info" })` |
1110
+ | `info` | breadcrumb | `addBreadcrumb({ level: "info" })` |
1111
+ | `debug` | breadcrumb | `addBreadcrumb({ level: "debug" })` |
1047
1112
 
1048
1113
  - **Errors keep their stack.** A `message` that is an `Error` goes through `captureException`, so Sentry parses the real stack and groups correctly — never pre-stringify the error.
1049
1114
  - **`module` / `action` become tags** and the entry's `context` becomes a structured Sentry context, both scoped to that single event via `withScope`.
@@ -1068,14 +1133,14 @@ Sentry sends events asynchronously over the network, so a synchronous flush can'
1068
1133
  ```ts
1069
1134
  async function shutdown() {
1070
1135
  await httpServer.close();
1071
- await log.flush(); // SentryLog.flush() → Sentry.flush(flushTimeout)
1136
+ await log.flush(); // SentryLog.flush() → Sentry.flush(flushTimeout)
1072
1137
  process.exit(0);
1073
1138
  }
1074
1139
 
1075
1140
  process.once("SIGTERM", shutdown);
1076
1141
  ```
1077
1142
 
1078
- `flushTimeout` (default `2000` ms) bounds the wait so an unreachable Sentry can't hang shutdown. `autoFlushOn` uses the **synchronous** `flushSync()`, which does *not* drain Sentry — wire `await log.flush()` yourself. See the `flush-logs-on-shutdown` topic.
1143
+ `flushTimeout` (default `2000` ms) bounds the wait so an unreachable Sentry can't hang shutdown. `autoFlushOn` uses the **synchronous** `flushSync()`, which does _not_ drain Sentry — wire `await log.flush()` yourself. See the `flush-logs-on-shutdown` topic.
1079
1144
 
1080
1145
  ## If `@sentry/node` isn't installed
1081
1146
 
@@ -1083,17 +1148,17 @@ The channel never crashes your app: the dynamic import failure is swallowed, the
1083
1148
 
1084
1149
  ## Config reference
1085
1150
 
1086
- | Option | Type | Default | Description |
1087
- |---|---|---|---|
1088
- | `client` | Sentry namespace / forwarder | — | Reuse an already-initialized Sentry instance. |
1089
- | `options` | `SentryInitOptions` | — | Sentry init options (mirrors `@sentry/node`'s `NodeOptions`) — used when the channel owns Sentry. |
1090
- | `eventLevels` | `LogLevel[]` | `["fatal", "error", "warn"]` | Levels sent as events; the rest become breadcrumbs. |
1091
- | `flushTimeout` | `number` | `2000` | Ms `flush()` waits for the transport to drain. |
1092
- | `levels`, `filter`, `dateFormat`, `redact` | — | — | Inherited from `BasicLogConfigurations`. |
1151
+ | Option | Type | Default | Description |
1152
+ | ------------------------------------------ | ---------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------- |
1153
+ | `client` | Sentry namespace / forwarder | — | Reuse an already-initialized Sentry instance. |
1154
+ | `options` | `SentryInitOptions` | — | Sentry init options (mirrors `@sentry/node`'s `NodeOptions`) — used when the channel owns Sentry. |
1155
+ | `eventLevels` | `LogLevel[]` | `["fatal", "error", "warn"]` | Levels sent as events; the rest become breadcrumbs. |
1156
+ | `flushTimeout` | `number` | `2000` | Ms `flush()` waits for the transport to drain. |
1157
+ | `levels`, `filter`, `dateFormat`, `redact` | — | — | Inherited from `BasicLogConfigurations`. |
1093
1158
 
1094
1159
  ## Don't do
1095
1160
 
1096
- - **Don't double-init Sentry.** Pass *either* `client` *or* `options`, not both. The channel guards against double-init, but a single owner of `Sentry.init` is cleaner.
1161
+ - **Don't double-init Sentry.** Pass _either_ `client` _or_ `options`, not both. The channel guards against double-init, but a single owner of `Sentry.init` is cleaner.
1097
1162
  - **Don't send every level as an event.** `eventLevels` with `info`/`debug`/`success` will flood your Sentry quota — keep them as breadcrumbs.
1098
1163
  - **Don't rely on `autoFlushOn` for Sentry.** It's synchronous; a network channel needs `await log.flush()`.
1099
1164
 
@@ -1149,7 +1214,9 @@ class CapturingChannel extends LogChannel {
1149
1214
  public name = "capture";
1150
1215
  public terminal = false;
1151
1216
  public received: LoggingData[] = [];
1152
- public log(data: LoggingData) { this.received.push({ ...data }); }
1217
+ public log(data: LoggingData) {
1218
+ this.received.push({ ...data });
1219
+ }
1153
1220
  }
1154
1221
  ```
1155
1222
 
@@ -1208,7 +1275,7 @@ No cleanup needed — the local `Logger` is garbage-collected.
1208
1275
 
1209
1276
  ## Why not spy on `log.info`?
1210
1277
 
1211
- `log` is a plain `Logger` instance (`export const log = new Logger()`) and every level method lives on the prototype, so `vi.spyOn(log, "info")` *does* technically work. Prefer the capturing channel anyway:
1278
+ `log` is a plain `Logger` instance (`export const log = new Logger()`) and every level method lives on the prototype, so `vi.spyOn(log, "info")` _does_ technically work. Prefer the capturing channel anyway:
1212
1279
 
1213
1280
  - A spy on `log.info` proves the method was **called**, not that an entry was **delivered** — it skips the whole pipeline (`minLevel` floor, redaction, per-channel `levels` / `filter`). A capturing channel asserts on the entry your code under test actually produced after all of that ran.
1214
1281
  - The `log` singleton is shared global state. A spy you forget to `mockRestore()` leaks into the next test; swapping `log.channels` and restoring it in `afterEach` is the same amount of code and isolates cleanly.
@@ -1378,6 +1445,7 @@ export class NullChannel extends LogChannel {
1378
1445
  ```
1379
1446
 
1380
1447
  Then:
1448
+
1381
1449
  ```ts
1382
1450
  log.addChannel(new NullChannel());
1383
1451
  ```
@@ -1386,16 +1454,16 @@ That's a working channel. `LogChannel` provides the scaffolding; you only need t
1386
1454
 
1387
1455
  ## What `LogChannel` gives you
1388
1456
 
1389
- | Thing | Who provides it |
1390
- |---|---|
1391
- | `name`, `description`, `terminal` | You (fields on your subclass) |
1392
- | `log(data)` | **You must implement** — abstract |
1393
- | `flushSync()` | You (optional sync drain — only if you buffer) |
1394
- | `flush()` | You (optional async drain — only if you buffer over async I/O) |
1395
- | `init()` | You (optional async hook — see below) |
1396
- | `shouldBeLogged(data)` | `LogChannel` — combines `levels` + `filter` |
1397
- | `config<K>(key)` | `LogChannel` — merges user config with `defaultConfigurations` |
1398
- | `getDateAndTimeFormat()` | `LogChannel` — returns resolved `dateFormat` |
1457
+ | Thing | Who provides it |
1458
+ | --------------------------------- | -------------------------------------------------------------- |
1459
+ | `name`, `description`, `terminal` | You (fields on your subclass) |
1460
+ | `log(data)` | **You must implement** — abstract |
1461
+ | `flushSync()` | You (optional sync drain — only if you buffer) |
1462
+ | `flush()` | You (optional async drain — only if you buffer over async I/O) |
1463
+ | `init()` | You (optional async hook — see below) |
1464
+ | `shouldBeLogged(data)` | `LogChannel` — combines `levels` + `filter` |
1465
+ | `config<K>(key)` | `LogChannel` — merges user config with `defaultConfigurations` |
1466
+ | `getDateAndTimeFormat()` | `LogChannel` — returns resolved `dateFormat` |
1399
1467
 
1400
1468
  ## Complete example — SlackLog
1401
1469
 
@@ -1413,7 +1481,7 @@ export class SlackLog extends LogChannel<SlackConfig> {
1413
1481
  public description = "Posts errors + warnings to a Slack webhook";
1414
1482
 
1415
1483
  public async log(data: LoggingData) {
1416
- if (!this.shouldBeLogged(data)) return; // ← inherit levels + filter
1484
+ if (!this.shouldBeLogged(data)) return; // ← inherit levels + filter
1417
1485
 
1418
1486
  await fetch(this.config("webhookUrl"), {
1419
1487
  method: "POST",
@@ -1444,9 +1512,7 @@ log.setChannels([
1444
1512
  Override `protected async init()` for one-time setup — open a socket, connect to a DB, prepare a write stream. Runs automatically after construction (inside a `setTimeout(0)`); `isInitialized` flips to `true` once resolved.
1445
1513
 
1446
1514
  ```ts
1447
- export class DatabaseLog extends LogChannel<
1448
- BasicLogConfigurations & { connectionString: string }
1449
- > {
1515
+ export class DatabaseLog extends LogChannel<BasicLogConfigurations & { connectionString: string }> {
1450
1516
  public name = "database";
1451
1517
  private client!: SomeDbClient;
1452
1518
 
@@ -1461,6 +1527,38 @@ export class DatabaseLog extends LogChannel<
1461
1527
  }
1462
1528
  ```
1463
1529
 
1530
+ ## Reporting background failures — `reportError()`
1531
+
1532
+ A failure inside `log()` reaches `log.onChannelError(...)` on its own. For work outside a `log()` call (a timer, a reconnect, a cleanup in `init()`), call `this.reportError(error, entry)`: the logger the channel was added to forwards it to its handler, and a channel not yet added writes one stderr line. It never throws.
1533
+
1534
+ ```ts
1535
+ export class QueueLog extends LogChannel {
1536
+ public name = "queue";
1537
+
1538
+ protected init() {
1539
+ setInterval(() => {
1540
+ this.drain().catch((error) =>
1541
+ this.reportError(error, {
1542
+ type: "error",
1543
+ module: "logger",
1544
+ action: "drain",
1545
+ message: "Queue drain failed",
1546
+ }),
1547
+ );
1548
+ }, 1_000);
1549
+ }
1550
+
1551
+ public log(data: LoggingData) {
1552
+ if (!this.shouldBeLogged(data)) return;
1553
+ // enqueue data …
1554
+ }
1555
+
1556
+ private async drain() {
1557
+ // send queued entries …
1558
+ }
1559
+ }
1560
+ ```
1561
+
1464
1562
  ## Implementing `flushSync()`
1465
1563
 
1466
1564
  Only if your channel buffers. Signature: `flushSync?(): void`. Synchronous — no `await`, no promises.
@@ -1482,7 +1580,9 @@ export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: str
1482
1580
  // so a follow-up async drain can recover it next boot.
1483
1581
  }
1484
1582
 
1485
- private async drain() { /* async post to this.config("url") */ }
1583
+ private async drain() {
1584
+ /* async post to this.config("url") */
1585
+ }
1486
1586
  }
1487
1587
  ```
1488
1588
 
@@ -1502,7 +1602,7 @@ export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: str
1502
1602
  }
1503
1603
 
1504
1604
  public async flush() {
1505
- await this.drain(); // awaited by log.flush() on shutdown
1605
+ await this.drain(); // awaited by log.flush() on shutdown
1506
1606
  }
1507
1607
 
1508
1608
  private async drain() {
@@ -1534,7 +1634,7 @@ class MinimalSlack implements LogContract {
1534
1634
 
1535
1635
  public async log(data: LoggingData) {
1536
1636
  if (data.type !== "error") return;
1537
- await fetch(process.env.SLACK_WEBHOOK!, { /* ... */ });
1637
+ await fetch(process.env.SLACK_WEBHOOK!, {/* ... */});
1538
1638
  }
1539
1639
  }
1540
1640
  ```