@warlock.js/logger 4.6.0 → 4.6.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.
package/llms-full.txt CHANGED
@@ -1,19 +1,19 @@
1
- # Warlock Logger — full skills
2
-
3
- > Package: `@warlock.js/logger`
4
-
5
- > Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/logger/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.
6
-
7
- ## capture-unhandled-errors `@warlock.js/logger/capture-unhandled-errors/SKILL.md`
8
-
1
+ # Warlock Logger — full skills
2
+
3
+ > Package: `@warlock.js/logger`
4
+
5
+ > Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/logger/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.
6
+
7
+ ## capture-unhandled-errors `@warlock.js/logger/capture-unhandled-errors/SKILL.md`
8
+
9
9
  ---
10
10
  name: capture-unhandled-errors
11
- description: 'captureAnyUnhandledRejection() installs process.on(''unhandledRejection'') → log.error and process.on(''uncaughtException'') → log.fatal so process-level failures land in your configured channels. Triggers: `captureAnyUnhandledRejection`, `unhandledRejection`, `uncaughtException`, `log.error`, `log.fatal`; "log unhandled promise rejections", "catch uncaught exceptions to a file", "record crashes before exit", "global error handler with logger"; typical import `import { captureAnyUnhandledRejection, log } from "@warlock.js/logger"`. Skip: flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `Sentry.init`, `@sentry/node`; native `process.on(''unhandledRejection'')`.'
11
+ description: 'captureAnyUnhandledRejection() installs process.on(''unhandledRejection'') → log.error and process.on(''uncaughtException'') → log.fatal + process.exit(1) so process-level failures land in your channels and a fatal crash is never silently swallowed into exit 0. Triggers: `captureAnyUnhandledRejection`, `exitOnUncaughtException`, `unhandledRejection`, `uncaughtException`, `log.error`, `log.fatal`; "log unhandled promise rejections", "catch uncaught exceptions to a file", "record crashes before exit", "server exits 0 with no error", "silent exit / production server stopped", "global error handler with logger"; typical import `import { captureAnyUnhandledRejection, log } from "@warlock.js/logger"`. Skip: flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `Sentry.init`, `@sentry/node`; native `process.on(''unhandledRejection'')`.'
12
12
  ---
13
13
 
14
14
  # Error capture — routing Node's unhandled errors through the logger
15
15
 
16
- `captureAnyUnhandledRejection()` installs two process-level listeners so crashes are logged (not silently swallowed) before Node exits.
16
+ `captureAnyUnhandledRejection()` installs two process-level listeners so crashes are logged and, for an `uncaughtException`, made loud and terminal — instead of being silently swallowed.
17
17
 
18
18
  ## What it does
19
19
 
@@ -24,10 +24,19 @@ captureAnyUnhandledRejection();
24
24
  ```
25
25
 
26
26
  Registers:
27
- - `process.on("unhandledRejection", reason => log.error("app", "unhandledRejection", reason))`
28
- - `process.on("uncaughtException", error => log.fatal("app", "uncaughtException", error))`
27
+ - `process.on("unhandledRejection", reason => log.error("app", "unhandledRejection", reason))` — logged; the process is kept alive.
28
+ - `process.on("uncaughtException", error => log.fatal("app", "uncaughtException", error))` — logged, then `process.exit(1)` (by default).
29
+
30
+ The split is intentional: an `uncaughtException` leaves the process in an undefined state, so it's semantically `fatal` and takes the process down. An `unhandledRejection` is a failure but not always process-ending (depends on Node's `--unhandled-rejections` policy and your app's recovery), so it stays at `error` and never exits. This makes "page on fatal" alerting clean — only true crashes ring the pager.
31
+
32
+ ## Why it exits (and why that matters)
33
+
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
+
36
+ - **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
+ - **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).
29
38
 
30
- The split is intentional: an `uncaughtException` terminates the Node process by default, so it's semantically `fatal`. An `unhandledRejection` is a failure but not always process-ending (depends on Node's `--unhandled-rejections` policy and your app's recovery), so it stays at `error`. This makes "page on fatal" alerting clean — only true crashes ring the pager.
39
+ > The framework wires this for you in `bootstrap()` as `captureAnyUnhandledRejection({ exitOnUncaughtException: Application.isProduction })` production crashes loudly and non-zero, the dev server logs-and-continues so HMR can recover.
31
40
 
32
41
  ## When to call it
33
42
 
@@ -49,15 +58,11 @@ log.configure({
49
58
  captureAnyUnhandledRejection();
50
59
  ```
51
60
 
52
- ## Pair with `autoFlushOn: ["beforeExit"]`
61
+ ## Flushing on the crash path
53
62
 
54
- Without a flush on exit, here's what happens on a crash:
63
+ 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.
55
64
 
56
- 1. Promise rejection fires `log.error(...)` queues the error into `FileLog`'s buffer.
57
- 2. Node exits.
58
- 3. Buffer is never flushed. **The error that killed your app is lost.**
59
-
60
- Including `"beforeExit"` in `autoFlushOn` closes the gap. Node fires `beforeExit` after the rejection handler resolves, the logger flushes, then Node exits. See [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md).
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 [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md).
61
66
 
62
67
  ## Idempotency — don't call it twice
63
68
 
@@ -65,7 +70,8 @@ Calling `captureAnyUnhandledRejection()` a second time registers a second pair o
65
70
 
66
71
  ## What it does **not** do
67
72
 
68
- - **Does not swallow errors.** Node still exits after `uncaughtException` (this is the safe behaviorstate is undefined). The logger just ensures the error is recorded first.
73
+ - **Does not swallow a fatal error.** After an `uncaughtException` it records the error, then exits non-zero itself restoring Node's own default, which merely registering the listener would otherwise suppress. Pass `{ exitOnUncaughtException: false }` only when the process is meant to survive (e.g. HMR).
74
+ - **Does not exit on an `unhandledRejection`.** That path only logs (at `error`); the process keeps running. Set Node's `--unhandled-rejections=throw` if you want a rejection to escalate to an `uncaughtException`.
69
75
  - **Does not install Node's `--unhandled-rejections` policy.** That's a Node flag; set it in your launch script if you want strict mode.
70
76
  - **Does not hook `SIGTERM` / `SIGINT`** — use `enableAutoFlush` for signal flushes.
71
77
  - **Does not filter.** Every rejection is logged at `error` and every uncaught exception at `fatal`, both with `module: "app"`. Filter per-channel if some noise slips in.
@@ -101,6 +107,8 @@ it("routes unhandled rejections to the logger", async () => {
101
107
  });
102
108
  ```
103
109
 
110
+ Testing the **`uncaughtException`** path additionally trips `process.exit(1)`, so stub it (`vi.spyOn(process, "exit").mockImplementation(() => undefined as never)`) or pass `{ exitOnUncaughtException: false }` — otherwise the emitted exception tears the test runner down. See [`@warlock.js/logger/test-logging-code/SKILL.md`](@warlock.js/logger/test-logging-code/SKILL.md).
111
+
104
112
  ## Module + action the capture uses
105
113
 
106
114
  Both listeners log with:
@@ -109,119 +117,119 @@ Both listeners log with:
109
117
  - `message`: the rejection reason / exception (keep it as the raw `Error` object — file channels capture the stack).
110
118
 
111
119
  If you want these routed to a specific file, filter on `data.module === "app"`. See [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md).
112
-
113
-
114
- ## configure-logger `@warlock.js/logger/configure-logger/SKILL.md`
115
-
116
- ---
117
- name: configure-logger
118
- description: 'Register channels via log.addChannel / log.setChannels / log.configure({channels, autoFlushOn, redact, minLevel}) at boot. Triggers: `log.configure`, `log.addChannel`, `log.setChannels`, `Logger`, `autoFlushOn`, `disableAutoFlush`; "wire channels at startup", "branch logger by NODE_ENV", "isolate a library''s logger", "replace channel list"; typical import `import { log, Logger, ConsoleLog, FileLog } from "@warlock.js/logger"`. Skip: channel picks — `@warlock.js/logger/pick-log-channel/SKILL.md`; flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; redaction — `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`; competing libs `winston.createLogger`, `pino`.'
119
- ---
120
-
121
- # Setup — registering channels at startup
122
-
123
- The logger is a singleton. Do all setup in one place, as early in the app entry point as possible.
124
-
125
- ## The three channel-registration methods
126
-
127
- | Method | Semantics |
128
- |---|---|
129
- | `log.addChannel(channel)` | **Appends.** Safe to call multiple times. |
130
- | `log.setChannels([...])` | **Replaces** the full list. |
131
- | `log.configure({ channels, autoFlushOn, redact, minLevel })` | **Replaces** channels if provided; installs auto-flush if provided; sets redact / minLevel if provided. All four are optional. |
132
-
133
- All three return `this` — chainable.
134
-
135
- ## Recommended pattern — one dedicated file
136
-
137
- ```ts title="src/logger.ts"
138
- import { log, ConsoleLog, FileLog, JSONFileLog } from "@warlock.js/logger";
139
-
140
- if (process.env.NODE_ENV === "production") {
141
- log.configure({
142
- channels: [
143
- new FileLog({ storagePath: "./storage/logs", chunk: "daily", rotate: true }),
144
- new JSONFileLog({ storagePath: "./storage/logs-json", chunk: "daily" }),
145
- ],
146
- autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
147
- });
148
- } else if (process.env.NODE_ENV === "test") {
149
- log.setChannels([]); // silence logger during tests
150
- } else {
151
- log.setChannels([new ConsoleLog()]);
152
- }
153
- ```
154
-
155
- Import it once at the top of `src/index.ts`:
156
-
157
- ```ts title="src/index.ts"
158
- import "./logger"; // side-effect: configures singleton
159
- import { log } from "@warlock.js/logger";
160
-
161
- log.info("app", "start", "Server listening on :3000");
162
- ```
163
-
164
- ## What `configure({ autoFlushOn })` does
165
-
166
- Registers one process-level handler per event that calls `log.flushSync()` before Node exits. See [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) for the full behavior table.
167
-
168
- ```ts
169
- log.configure({
170
- channels: [new FileLog()],
171
- autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
172
- });
173
- // Now a buffered FileLog flushes on Ctrl+C, container stop, and natural exit.
174
- ```
175
-
176
- Calling `configure({ autoFlushOn })` a second time **replaces** previous handlers (not stacks them). Call `log.disableAutoFlush()` to tear them down.
177
-
178
- ## Creating an isolated Logger
179
-
180
- Rarely needed. Useful when a library wants its own channel list that doesn't share with the host app:
181
-
182
- ```ts
183
- import { Logger, ConsoleLog } from "@warlock.js/logger";
184
-
185
- export const libraryLogger = new Logger();
186
- libraryLogger.addChannel(new ConsoleLog({ filter: (d) => d.module === "my-lib" }));
187
- ```
188
-
189
- Every `new Logger()` gets a unique `id` (string, prefixed `"logger-"`).
190
-
191
- ## Order matters — ANSI stripping across channels
192
-
193
- `Logger.log` shallow-clones the entry per non-terminal channel before stripping ANSI codes. Registering a terminal channel (ConsoleLog) **after** a non-terminal one (FileLog) still works — ConsoleLog sees the original colored message. But if you register them in reverse and add a channel that mutates `data` in place, the non-terminal channel will see the terminal channel's version. Prefer the built-ins; custom channels should not mutate `data`.
194
-
195
- ## When to call what
196
-
197
- - **`addChannel`** — most common. Add channels as you discover you need them during setup.
198
- - **`setChannels`** — when env branching makes the full list clear at once (production vs dev).
199
- - **`configure`** — when you also want to install auto-flush, redact, or minLevel in the same call.
200
-
201
- ## Combining everything
202
-
203
- ```ts
204
- log.configure({
205
- channels: [
206
- new ConsoleLog({ showContext: true }),
207
- new FileLog({ chunk: "daily" }),
208
- ],
209
- autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
210
- redact: { paths: ["context.password", "context.headers.authorization"] },
211
- minLevel: process.env.LOG_LEVEL === "debug" ? "debug" : "info",
212
- });
213
- ```
214
-
215
- See [`@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md) for the redact contract and [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md) for `minLevel`.
216
-
217
- ## See also
218
-
219
- - [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md) — what each built-in channel does
220
- - [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) — `autoFlushOn` event behavior
221
-
222
-
223
- ## filter-log-entries `@warlock.js/logger/filter-log-entries/SKILL.md`
224
-
120
+
121
+
122
+ ## configure-logger `@warlock.js/logger/configure-logger/SKILL.md`
123
+
124
+ ---
125
+ name: configure-logger
126
+ description: 'Register channels via log.addChannel / log.setChannels / log.configure({channels, autoFlushOn, redact, minLevel}) at boot. Triggers: `log.configure`, `log.addChannel`, `log.setChannels`, `Logger`, `autoFlushOn`, `disableAutoFlush`; "wire channels at startup", "branch logger by NODE_ENV", "isolate a library''s logger", "replace channel list"; typical import `import { log, Logger, ConsoleLog, FileLog } from "@warlock.js/logger"`. Skip: channel picks — `@warlock.js/logger/pick-log-channel/SKILL.md`; flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; redaction — `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`; competing libs `winston.createLogger`, `pino`.'
127
+ ---
128
+
129
+ # Setup — registering channels at startup
130
+
131
+ The logger is a singleton. Do all setup in one place, as early in the app entry point as possible.
132
+
133
+ ## The three channel-registration methods
134
+
135
+ | Method | Semantics |
136
+ |---|---|
137
+ | `log.addChannel(channel)` | **Appends.** Safe to call multiple times. |
138
+ | `log.setChannels([...])` | **Replaces** the full list. |
139
+ | `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
+
141
+ All three return `this` — chainable.
142
+
143
+ ## Recommended pattern — one dedicated file
144
+
145
+ ```ts title="src/logger.ts"
146
+ import { log, ConsoleLog, FileLog, JSONFileLog } from "@warlock.js/logger";
147
+
148
+ if (process.env.NODE_ENV === "production") {
149
+ log.configure({
150
+ channels: [
151
+ new FileLog({ storagePath: "./storage/logs", chunk: "daily", rotate: true }),
152
+ new JSONFileLog({ storagePath: "./storage/logs-json", chunk: "daily" }),
153
+ ],
154
+ autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
155
+ });
156
+ } else if (process.env.NODE_ENV === "test") {
157
+ log.setChannels([]); // silence logger during tests
158
+ } else {
159
+ log.setChannels([new ConsoleLog()]);
160
+ }
161
+ ```
162
+
163
+ Import it once at the top of `src/index.ts`:
164
+
165
+ ```ts title="src/index.ts"
166
+ import "./logger"; // side-effect: configures singleton
167
+ import { log } from "@warlock.js/logger";
168
+
169
+ log.info("app", "start", "Server listening on :3000");
170
+ ```
171
+
172
+ ## What `configure({ autoFlushOn })` does
173
+
174
+ Registers one process-level handler per event that calls `log.flushSync()` before Node exits. See [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) for the full behavior table.
175
+
176
+ ```ts
177
+ log.configure({
178
+ channels: [new FileLog()],
179
+ autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
180
+ });
181
+ // Now a buffered FileLog flushes on Ctrl+C, container stop, and natural exit.
182
+ ```
183
+
184
+ Calling `configure({ autoFlushOn })` a second time **replaces** previous handlers (not stacks them). Call `log.disableAutoFlush()` to tear them down.
185
+
186
+ ## Creating an isolated Logger
187
+
188
+ Rarely needed. Useful when a library wants its own channel list that doesn't share with the host app:
189
+
190
+ ```ts
191
+ import { Logger, ConsoleLog } from "@warlock.js/logger";
192
+
193
+ export const libraryLogger = new Logger();
194
+ libraryLogger.addChannel(new ConsoleLog({ filter: (d) => d.module === "my-lib" }));
195
+ ```
196
+
197
+ Every `new Logger()` gets a unique `id` (string, prefixed `"logger-"`).
198
+
199
+ ## Order matters — ANSI stripping across channels
200
+
201
+ `Logger.log` shallow-clones the entry per non-terminal channel before stripping ANSI codes. Registering a terminal channel (ConsoleLog) **after** a non-terminal one (FileLog) still works — ConsoleLog sees the original colored message. But if you register them in reverse and add a channel that mutates `data` in place, the non-terminal channel will see the terminal channel's version. Prefer the built-ins; custom channels should not mutate `data`.
202
+
203
+ ## When to call what
204
+
205
+ - **`addChannel`** — most common. Add channels as you discover you need them during setup.
206
+ - **`setChannels`** — when env branching makes the full list clear at once (production vs dev).
207
+ - **`configure`** — when you also want to install auto-flush, redact, or minLevel in the same call.
208
+
209
+ ## Combining everything
210
+
211
+ ```ts
212
+ log.configure({
213
+ channels: [
214
+ new ConsoleLog({ showContext: true }),
215
+ new FileLog({ chunk: "daily" }),
216
+ ],
217
+ autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
218
+ redact: { paths: ["context.password", "context.headers.authorization"] },
219
+ minLevel: process.env.LOG_LEVEL === "debug" ? "debug" : "info",
220
+ });
221
+ ```
222
+
223
+ See [`@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md) for the redact contract and [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md) for `minLevel`.
224
+
225
+ ## See also
226
+
227
+ - [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md) — what each built-in channel does
228
+ - [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) — `autoFlushOn` event behavior
229
+
230
+
231
+ ## filter-log-entries `@warlock.js/logger/filter-log-entries/SKILL.md`
232
+
225
233
  ---
226
234
  name: filter-log-entries
227
235
  description: 'Drop log entries — per-channel levels whitelist, per-channel filter predicate, logger-wide setMinLevel(level) fast path. Triggers: `levels`, `filter`, `minLevel`, `log.setMinLevel`, `shouldBeLogged`, `LoggingData`, `LogLevel`; "silence a noisy module", "route errors to a dedicated file", "raise global severity floor", "drop debug logs in prod"; typical import `import { log } from "@warlock.js/logger"`. Skip: custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; channel picks — `@warlock.js/logger/pick-log-channel/SKILL.md`; competing libs `pino.levels`, `winston.format.filter`, `debug` env var.'
@@ -342,10 +350,10 @@ There is no `logger.setGlobalFilter()`. Each channel filters itself. If you want
342
350
  Filters run on **every** entry per channel. A synchronous, cheap predicate is fine. Avoid `await` inside — the channel receives a fully-formed `LoggingData` and the filter is sync-only (type: `(data: LoggingData) => boolean`).
343
351
 
344
352
  The `minLevel` check is the fastest of the three (single comparison before fan-out), so prefer it when "drop everything below X uniformly" matches your need.
345
-
346
-
347
- ## flush-logs-on-shutdown `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`
348
-
353
+
354
+
355
+ ## flush-logs-on-shutdown `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`
356
+
349
357
  ---
350
358
  name: flush-logs-on-shutdown
351
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 — `@warlock.js/logger/capture-unhandled-errors/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing `pino.final`, `winston.end`; native `process.on(''exit'')`.'
@@ -447,7 +455,7 @@ log.flushSync();
447
455
 
448
456
  ## Unhandled errors
449
457
 
450
- If you use [`captureAnyUnhandledRejection()`](@warlock.js/logger/capture-unhandled-errors/SKILL.md), **include `"beforeExit"` in `autoFlushOn`**. Otherwise a crash logs the error into the buffer, then the process exits before the 5-second flush interval fires.
458
+ The `uncaughtException` path in [`captureAnyUnhandledRejection()`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) 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.
451
459
 
452
460
  ```ts
453
461
  log.configure({
@@ -463,10 +471,10 @@ captureAnyUnhandledRejection();
463
471
  - **Don't `await` inside a signal handler you wrote yourself and then call `flushSync`** — if an async step rejects, you skip the flush. Wrap in `try { await x } finally { log.flushSync(); process.exit(1); }`.
464
472
  - **Don't call `process.exit()` inside `autoFlushOn` handlers** — signal handlers here already re-raise the signal. Forcing an exit breaks exit codes.
465
473
  - **Don't rely on the 5-second flush interval for shutdown safety.** It's a throughput optimization, not a durability guarantee.
466
-
467
-
468
- ## logger-basics `@warlock.js/logger/logger-basics/SKILL.md`
469
-
474
+
475
+
476
+ ## logger-basics `@warlock.js/logger/logger-basics/SKILL.md`
477
+
470
478
  ---
471
479
  name: logger-basics
472
480
  description: 'Start with @warlock.js/logger — the log singleton, six levels (debug / info / warn / error / success / fatal), channel fan-out, foundations. Triggers: `log`, `Logger`, `log.info`, `log.error`, `log.fatal`, `log.debug`, `log.warn`, `log.success`, `ConsoleLog`, `FileLog`, `JSONFileLog`; "how do I log in node", "warlock logger basics", "which logger skill do I need"; typical import `import { log, ConsoleLog, FileLog } from "@warlock.js/logger"`. Skip: channel picks — `@warlock.js/logger/pick-log-channel/SKILL.md`; setup — `@warlock.js/logger/configure-logger/SKILL.md`; competing libs `winston`, `pino`, `bunyan`, `log4js`, `signale`; native `console.log`.'
@@ -555,10 +563,10 @@ Every call signature is the same — `module`, `action`, `message`, optional `co
555
563
  - Don't `await log.info(...)` expecting the write to be on disk — `FileLog` buffers. Call `log.flushSync()` (or rely on `autoFlushOn`) before the process exits.
556
564
  - Don't call `captureAnyUnhandledRejection()` more than once — it re-registers listeners every call and your rejections get logged N times.
557
565
  - Don't shadow the import in local code: `for (const log of logEntries) { ... }` will hide the singleton inside that block. Rename loop variables (`entry`, `record`) when working with logger imports.
558
-
559
-
560
- ## overview `@warlock.js/logger/overview/SKILL.md`
561
-
566
+
567
+
568
+ ## overview `@warlock.js/logger/overview/SKILL.md`
569
+
562
570
  ---
563
571
  name: overview
564
572
  description: 'Front-door orientation for `@warlock.js/logger` — structured channel-based logging with six severity levels (debug / info / warn / error / success / fatal), PII redaction floor, buffered file/JSON channels, optional SentryLog forwarding, async log.flush() + signal-flush on shutdown, ergonomic helpers (timer, assert). Standalone — no `@warlock.js/core` required. TRIGGER when: code imports anything from `@warlock.js/logger`; user asks "what does @warlock.js/logger do", "compare with pino / winston / bunyan", "structured logging for Node", "which logger should I use", "how do channels work"; package.json adds `@warlock.js/logger`. Skip: specific task already known — load the matching task skill directly (`logger-basics`, `configure-logger`, `pick-log-channel`, `write-custom-log-channel`, `ship-logs-to-sentry`, `redact-sensitive-log-fields`, `filter-log-entries`, `flush-logs-on-shutdown`, `capture-unhandled-errors`, `use-log-helpers`, `test-logging-code`); plain `console.log` in throwaway scripts.'
@@ -617,7 +625,7 @@ Drop entries before they cost anything. Logger-wide `setMinLevel("info")` is the
617
625
  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.
618
626
 
619
627
  #### [`capture-unhandled-errors`](@warlock.js/logger/capture-unhandled-errors/SKILL.md)
620
- `captureAnyUnhandledRejection()` hooks `unhandledRejection` (→ `log.error("app", ...)`) and `uncaughtException` (→ `log.fatal("app", ...)` — Node terminates by default, so it's semantically fatal). One call at startup, pair with `autoFlushOn: ['beforeExit']` to land the entry on disk.
628
+ `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.
621
629
 
622
630
  ### Ergonomics + testing
623
631
 
@@ -649,10 +657,10 @@ Silence the logger globally in tests via `log.setChannels([])` in `setupFiles`.
649
657
 
650
658
  - [`@warlock.js/core/warlock-conventions`](@warlock.js/core/warlock-conventions/SKILL.md) — the parent framework's conventions; logger is one of its foundation packages and ships transitively when you install core.
651
659
  - When synced via agent-kit, this `overview/SKILL.md` is flattened to the front-door skill `.claude/skills/warlock-js-logger-overview/` — every cross-link above uses the `@warlock.js/logger/<skill>/SKILL.md` name form so it survives that flattening.
652
-
653
-
654
- ## pick-log-channel `@warlock.js/logger/pick-log-channel/SKILL.md`
655
-
660
+
661
+
662
+ ## pick-log-channel `@warlock.js/logger/pick-log-channel/SKILL.md`
663
+
656
664
  ---
657
665
  name: pick-log-channel
658
666
  description: 'Pick one of the four built-in channels — ConsoleLog (terminal), FileLog (plain text on disk), JSONFileLog (structured JSON for aggregators like Loki / Datadog / Elastic), SentryLog (forwards errors + breadcrumbs to Sentry). Triggers: `ConsoleLog`, `FileLog`, `JSONFileLog`, `SentryLog`, `chunk`, `rotate`, `groupBy`, `maxFileSize`, `showContext`, `log.channel`; "log to a file", "rotate log files", "daily log chunks", "json logs for datadog / loki / elastic", "send logs to Sentry"; typical import `import { ConsoleLog, FileLog, JSONFileLog, SentryLog } from "@warlock.js/logger"`. Skip: Sentry-specific setup — `@warlock.js/logger/ship-logs-to-sentry/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; registration — `@warlock.js/logger/configure-logger/SKILL.md`; competing libs `winston-daily-rotate-file`, `pino-pretty`.'
@@ -808,136 +816,136 @@ If two channels share a `name`, only one is reachable this way — the search re
808
816
  - [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md) — `levels` and `filter` config in detail
809
817
  - [`@warlock.js/logger/ship-logs-to-sentry/SKILL.md`](@warlock.js/logger/ship-logs-to-sentry/SKILL.md) — the `SentryLog` channel in depth
810
818
  - [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md) — extending `LogChannel` for custom sinks
811
-
812
-
813
- ## redact-sensitive-log-fields `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`
814
-
815
- ---
816
- name: redact-sensitive-log-fields
817
- description: 'Strip secrets from log output — two-layer additive redaction via log.configure({redact: {paths}}) (logger floor) + per-channel redact (more paths on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing libs `pino.redact`, `fast-redact`.'
818
- ---
819
-
820
- # Redaction — keeping secrets out of logs
821
-
822
- Two layers, both opt-in. Configured at the logger and/or per channel.
823
-
824
- ## The model in one line
825
-
826
- > Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
827
-
828
- That guarantee is the whole point — once you've set `password` to redact at the logger, you can audit one place to know nothing leaks it, regardless of how many channels you add.
829
-
830
- ## Logger-wide floor
831
-
832
- ```ts
833
- import { log } from "@warlock.js/logger";
834
-
835
- log.configure({
836
- redact: {
837
- paths: [
838
- "context.password",
839
- "context.*.token",
840
- "context.headers.authorization",
841
- ],
842
- censor: "[REDACTED]", // default — string or function
843
- },
844
- });
845
-
846
- // runtime equivalent:
847
- log.setRedact({ paths: ["context.password"] });
848
- log.setRedact(undefined); // clear
849
- ```
850
-
851
- Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
852
-
853
- ## Per-channel additive
854
-
855
- ```ts
856
- new SlackChannel({
857
- webhook: "...",
858
- redact: {
859
- paths: ["context.user.email", "context.metadata.*"],
860
- // censor inherited from logger-wide when omitted
861
- },
862
- });
863
- ```
864
-
865
- The channel's `paths` are **merged** with the logger floor — the channel runs a single combined redact pass, never replaces the floor. The channel's `censor` (if provided) wins for both its own and the logger's paths in this channel only; the logger floor still uses its own censor for other channels.
866
-
867
- ### When to set redact per-channel
868
-
869
- - Loud destinations with broader audiences (Slack, Discord, error trackers, anything off your machine) — redact more aggressively.
870
- - Local-only destinations (FileLog you alone read, the dev terminal) — keep the floor minimal so you can debug.
871
-
872
- ### When NOT to set it
873
-
874
- If you want raw context in your dev terminal, **don't add redact at the logger level** — set it only on the file/JSON/network channels. Logger-wide is the floor, so it applies everywhere; you can't opt a single channel out.
875
-
876
- ## Path syntax
877
-
878
- Paths are dotted glob patterns evaluated against the full `LoggingData`:
879
-
880
- ```
881
- type LoggingData = {
882
- type: "info" | ...,
883
- module: string,
884
- action: string,
885
- message: any, // ← prefix paths with "message." to redact here
886
- context?: object, // ← prefix paths with "context." to redact here
887
- };
888
- ```
889
-
890
- | Pattern | Matches |
891
- | --- | --- |
892
- | `context.password` | exactly `data.context.password` |
893
- | `context.*.token` | `data.context.<any>.token` (one segment in between) |
894
- | `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
895
- | `message.apiKey` | when message is an object, `data.message.apiKey` |
896
- | `context.users.*.token` | array element redaction (`*` matches indices too) |
897
-
898
- Wildcards:
899
-
900
- - `*` — exactly one segment (any object key, any array index).
901
- - `**` — zero or more segments, greedily; matches at any depth.
902
-
903
- ## Censor variants
904
-
905
- ```ts
906
- // String — replace with a literal.
907
- { censor: "[REDACTED]" }
908
- { censor: "***" }
909
-
910
- // Function — receives original value + dotted path, returns the replacement.
911
- {
912
- censor: (value, path) => {
913
- if (typeof value !== "string") return "[REDACTED]";
914
- return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***";
915
- },
916
- }
917
- ```
918
-
919
- Function censors are called for every match — keep them cheap. The path is the actual matched location (e.g. `"context.users.0.token"` for an array hit).
920
-
921
- ## Immutability
922
-
923
- `applyRedact` always returns a deep clone — your input data is never mutated. `Date` and `Error` instances are reconstructed (so `instanceof` checks still work). Circular references are tolerated.
924
-
925
- ## What about the `message` field?
926
-
927
- If `message` is a plain object, paths under `message.*` work as expected. If `message` is a string (the most common case), redaction won't scan it — string scrubbing requires regex and is out of scope for this primitive. Wrap secrets in `context` and they'll be redacted reliably.
928
-
929
- ## Performance notes
930
-
931
- - **No redact configured** → zero overhead (no clone, no walk).
932
- - **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
933
- - **Channel adds paths** → that channel re-clones from the original input and runs the merged pass once. Other channels still share the cheaper logger-wide clone.
934
- - Each path is matched independently; cost grows linearly with `paths.length`.
935
-
936
- 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.
937
-
938
-
939
- ## ship-logs-to-sentry `@warlock.js/logger/ship-logs-to-sentry/SKILL.md`
940
-
819
+
820
+
821
+ ## redact-sensitive-log-fields `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`
822
+
823
+ ---
824
+ name: redact-sensitive-log-fields
825
+ description: 'Strip secrets from log output — two-layer additive redaction via log.configure({redact: {paths}}) (logger floor) + per-channel redact (more paths on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing libs `pino.redact`, `fast-redact`.'
826
+ ---
827
+
828
+ # Redaction — keeping secrets out of logs
829
+
830
+ Two layers, both opt-in. Configured at the logger and/or per channel.
831
+
832
+ ## The model in one line
833
+
834
+ > Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
835
+
836
+ That guarantee is the whole point — once you've set `password` to redact at the logger, you can audit one place to know nothing leaks it, regardless of how many channels you add.
837
+
838
+ ## Logger-wide floor
839
+
840
+ ```ts
841
+ import { log } from "@warlock.js/logger";
842
+
843
+ log.configure({
844
+ redact: {
845
+ paths: [
846
+ "context.password",
847
+ "context.*.token",
848
+ "context.headers.authorization",
849
+ ],
850
+ censor: "[REDACTED]", // default — string or function
851
+ },
852
+ });
853
+
854
+ // runtime equivalent:
855
+ log.setRedact({ paths: ["context.password"] });
856
+ log.setRedact(undefined); // clear
857
+ ```
858
+
859
+ Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
860
+
861
+ ## Per-channel additive
862
+
863
+ ```ts
864
+ new SlackChannel({
865
+ webhook: "...",
866
+ redact: {
867
+ paths: ["context.user.email", "context.metadata.*"],
868
+ // censor inherited from logger-wide when omitted
869
+ },
870
+ });
871
+ ```
872
+
873
+ The channel's `paths` are **merged** with the logger floor — the channel runs a single combined redact pass, never replaces the floor. The channel's `censor` (if provided) wins for both its own and the logger's paths in this channel only; the logger floor still uses its own censor for other channels.
874
+
875
+ ### When to set redact per-channel
876
+
877
+ - Loud destinations with broader audiences (Slack, Discord, error trackers, anything off your machine) — redact more aggressively.
878
+ - Local-only destinations (FileLog you alone read, the dev terminal) — keep the floor minimal so you can debug.
879
+
880
+ ### When NOT to set it
881
+
882
+ If you want raw context in your dev terminal, **don't add redact at the logger level** — set it only on the file/JSON/network channels. Logger-wide is the floor, so it applies everywhere; you can't opt a single channel out.
883
+
884
+ ## Path syntax
885
+
886
+ Paths are dotted glob patterns evaluated against the full `LoggingData`:
887
+
888
+ ```
889
+ type LoggingData = {
890
+ type: "info" | ...,
891
+ module: string,
892
+ action: string,
893
+ message: any, // ← prefix paths with "message." to redact here
894
+ context?: object, // ← prefix paths with "context." to redact here
895
+ };
896
+ ```
897
+
898
+ | Pattern | Matches |
899
+ | --- | --- |
900
+ | `context.password` | exactly `data.context.password` |
901
+ | `context.*.token` | `data.context.<any>.token` (one segment in between) |
902
+ | `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
903
+ | `message.apiKey` | when message is an object, `data.message.apiKey` |
904
+ | `context.users.*.token` | array element redaction (`*` matches indices too) |
905
+
906
+ Wildcards:
907
+
908
+ - `*` — exactly one segment (any object key, any array index).
909
+ - `**` — zero or more segments, greedily; matches at any depth.
910
+
911
+ ## Censor variants
912
+
913
+ ```ts
914
+ // String — replace with a literal.
915
+ { censor: "[REDACTED]" }
916
+ { censor: "***" }
917
+
918
+ // Function — receives original value + dotted path, returns the replacement.
919
+ {
920
+ censor: (value, path) => {
921
+ if (typeof value !== "string") return "[REDACTED]";
922
+ return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***";
923
+ },
924
+ }
925
+ ```
926
+
927
+ Function censors are called for every match — keep them cheap. The path is the actual matched location (e.g. `"context.users.0.token"` for an array hit).
928
+
929
+ ## Immutability
930
+
931
+ `applyRedact` always returns a deep clone — your input data is never mutated. `Date` and `Error` instances are reconstructed (so `instanceof` checks still work). Circular references are tolerated.
932
+
933
+ ## What about the `message` field?
934
+
935
+ If `message` is a plain object, paths under `message.*` work as expected. If `message` is a string (the most common case), redaction won't scan it — string scrubbing requires regex and is out of scope for this primitive. Wrap secrets in `context` and they'll be redacted reliably.
936
+
937
+ ## Performance notes
938
+
939
+ - **No redact configured** → zero overhead (no clone, no walk).
940
+ - **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
941
+ - **Channel adds paths** → that channel re-clones from the original input and runs the merged pass once. Other channels still share the cheaper logger-wide clone.
942
+ - Each path is matched independently; cost grows linearly with `paths.length`.
943
+
944
+ 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.
945
+
946
+
947
+ ## ship-logs-to-sentry `@warlock.js/logger/ship-logs-to-sentry/SKILL.md`
948
+
941
949
  ---
942
950
  name: ship-logs-to-sentry
943
951
  description: 'Forward log entries to Sentry with the SentryLog channel — error/warn become events (captureException/captureMessage), every other level a breadcrumb (no quota). @sentry/node is an OPTIONAL peer, lazily imported. Triggers: `SentryLog`, `@sentry/node`, `eventLevels`, `flushTimeout`, `Sentry.flush`, `captureException`, `addBreadcrumb`, `withScope`; "send logs to Sentry", "report errors to Sentry", "Sentry log channel", "Sentry breadcrumbs from logs", "log channel for Sentry"; typical import `import { SentryLog } from "@warlock.js/logger"`. Skip: file/console channels — `@warlock.js/logger/pick-log-channel/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; graceful-shutdown flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; Slack alerting recipe.'
@@ -1056,253 +1064,261 @@ The channel never crashes your app: the dynamic import failure is swallowed, the
1056
1064
  - [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) — `await log.flush()` on shutdown
1057
1065
  - [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md) — the console / file channels
1058
1066
  - [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md) — build your own sink
1059
-
1060
-
1061
- ## test-logging-code `@warlock.js/logger/test-logging-code/SKILL.md`
1062
-
1063
- ---
1064
- name: test-logging-code
1065
- description: 'Test code that touches the logger — silence globally via log.setChannels([]) in setupFiles, assert specific log lines via a capturing LogChannel subclass (prefer it over vi.spyOn — it asserts on delivered entries, not just method calls, and isolates the shared singleton cleanly). Triggers: `log.setChannels`, `LogChannel`, `LoggingData`, `Logger`, `log.channels`; "silence logger in vitest", "assert a log line was emitted", "capture log output in tests", "test code that logs"; typical import `import { log, Logger, LogChannel, type LoggingData } from "@warlock.js/logger"`. Skip: custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `vi.spyOn(console)`, `jest.spyOn`.'
1066
- ---
1067
-
1068
- # Testing — code that logs, and asserting on log output
1069
-
1070
- Two scenarios: **silencing the logger during tests** (most common) and **asserting that a specific log line was emitted**.
1071
-
1072
- ## Silence the logger during tests
1073
-
1074
- Clear every channel once, globally. No output, no file handles, no noise.
1075
-
1076
- ```ts title="src/setupTests.ts"
1077
- import { log } from "@warlock.js/logger";
1078
-
1079
- log.setChannels([]);
1080
- ```
1081
-
1082
- Wire it in Vitest:
1083
-
1084
- ```ts title="vitest.config.ts"
1085
- import { defineConfig } from "vitest/config";
1086
-
1087
- export default defineConfig({
1088
- test: {
1089
- setupFiles: ["src/setupTests.ts"],
1090
- },
1091
- });
1092
- ```
1093
-
1094
- ## Assert on log output — use a capturing channel
1095
-
1096
- Don't spy on `console.log` and don't mock `log.info` — assert on what a channel actually received instead (see "Why not spy on `log.info`?" below). The cleanest pattern is a tiny channel that records what it sees:
1097
-
1098
- ```ts
1099
- import { LogChannel } from "@warlock.js/logger";
1100
- import type { LoggingData } from "@warlock.js/logger";
1101
-
1102
- class CapturingChannel extends LogChannel {
1103
- public name = "capture";
1104
- public terminal = false;
1105
- public received: LoggingData[] = [];
1106
- public log(data: LoggingData) { this.received.push({ ...data }); }
1107
- }
1108
- ```
1109
-
1110
- ### Test against the singleton
1111
-
1112
- ```ts
1113
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
1114
- import { log } from "@warlock.js/logger";
1115
- import { createUser } from "./users";
1116
-
1117
- describe("createUser", () => {
1118
- let capture: CapturingChannel;
1119
- let originalChannels: typeof log.channels;
1120
-
1121
- beforeEach(() => {
1122
- capture = new CapturingChannel();
1123
- originalChannels = log.channels;
1124
- log.channels = [capture];
1125
- });
1126
-
1127
- afterEach(() => {
1128
- log.channels = originalChannels;
1129
- });
1130
-
1131
- it("logs a success entry when the user is created", async () => {
1132
- await createUser({ email: "a@b.com" });
1133
-
1134
- expect(capture.received).toContainEqual(
1135
- expect.objectContaining({
1136
- type: "success",
1137
- module: "users",
1138
- action: "create",
1139
- }),
1140
- );
1141
- });
1142
- });
1143
- ```
1144
-
1145
- ### Test an isolated logger (avoid touching the singleton)
1146
-
1147
- If the code under test accepts a logger via injection, create one per test:
1148
-
1149
- ```ts
1150
- import { Logger } from "@warlock.js/logger";
1151
-
1152
- const testLogger = new Logger();
1153
- const capture = new CapturingChannel();
1154
- testLogger.addChannel(capture);
1155
-
1156
- await createUser({ email: "a@b.com" }, testLogger);
1157
-
1158
- expect(capture.received[0]!.type).toBe("success");
1159
- ```
1160
-
1161
- No cleanup needed — the local `Logger` is garbage-collected.
1162
-
1163
- ## Why not spy on `log.info`?
1164
-
1165
- `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:
1166
-
1167
- - 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.
1168
- - 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.
1169
- - Code that logs through `log.error(...)` and the bare object form `log.log({ type, ... })` both land in channels, but only the level shortcut goes through `log.info` — a channel catches both.
1170
-
1171
- So capture through a channel as shown above; reach for a method spy only when you specifically want to assert "this exact shortcut was invoked".
1172
-
1173
- ## Testing a custom channel
1174
-
1175
- Write specs against the channel directly; don't route through `Logger`:
1176
-
1177
- ```ts
1178
- import { describe, it, expect, vi } from "vitest";
1179
- import { SlackLog } from "./slack-log";
1180
-
1181
- describe("SlackLog", () => {
1182
- it("skips non-error levels", async () => {
1183
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response());
1184
-
1185
- const channel = new SlackLog({ webhookUrl: "https://test", levels: ["error"] });
1186
- await channel.log({ type: "info", module: "x", action: "y", message: "z" });
1187
-
1188
- expect(fetchSpy).not.toHaveBeenCalled();
1189
- });
1190
- });
1191
- ```
1192
-
1193
- ## Testing `FileLog` and `JSONFileLog`
1194
-
1195
- Use real temp directories — it's the only way to exercise file IO, rotation, chunking, and JSON I/O with fidelity:
1196
-
1197
- ```ts
1198
- import fs from "fs";
1199
- import os from "os";
1200
- import path from "path";
1201
- import { randomUUID } from "node:crypto";
1202
-
1203
- function tempDir() {
1204
- const dir = path.join(os.tmpdir(), "logger-test", randomUUID());
1205
- fs.mkdirSync(dir, { recursive: true });
1206
- return dir;
1207
- }
1208
- ```
1209
-
1210
- Clean up in `afterEach(() => fs.rmSync(dir, { recursive: true, force: true }))`.
1211
-
1212
- ## Waiting for async init
1213
-
1214
- `LogChannel.init()` runs inside a `setTimeout(0)`. Before asserting on post-init behavior, yield once:
1215
-
1216
- ```ts
1217
- const channel = new FileLog({ storagePath: tempDir() });
1218
- await new Promise((r) => setTimeout(r, 10));
1219
- // Now `channel.isInitialized` is true and it's safe to call `channel.log(...)` for real I/O.
1220
- ```
1221
-
1222
- ## Testing `captureAnyUnhandledRejection`
1223
-
1224
- Don't actually throw unhandled rejections in tests — emit the listener directly:
1225
-
1226
- ```ts
1227
- captureAnyUnhandledRejection();
1228
- process.emit("unhandledRejection", new Error("test"), Promise.resolve());
1229
- ```
1230
-
1231
- See [`@warlock.js/logger/capture-unhandled-errors/SKILL.md`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) for a full example.
1232
-
1233
-
1234
- ## use-log-helpers `@warlock.js/logger/use-log-helpers/SKILL.md`
1235
-
1236
- ---
1237
- name: use-log-helpers
1238
- description: 'Two DX shortcuts on every Logger — log.assert(condition, module, action, message, context?) logs an error when condition is falsy (free on the happy path), log.timer(module, action) returns an end-function emitting an info entry with measured duration. Triggers: `log.assert`, `log.timer`, `durationMs`; "assert an invariant via logger", "measure how long an operation took", "time a request", "log operation duration"; typical import `import { log } from "@warlock.js/logger"`. Skip: basics — `@warlock.js/logger/logger-basics/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `console.assert`, `console.time`, `console.timeEnd`, `perf_hooks.performance.now`.'
1239
- ---
1240
-
1241
- # Helpers — `assert`, `timer`
1242
-
1243
- Two small DX shortcuts on every `Logger` (and the bound `log` helper). They route through the normal log pipeline — every channel sees what they emit.
1244
-
1245
- ## `log.assert(condition, module, action, message, context?)`
1246
-
1247
- Logs an `error` entry when `condition` is falsy. Genuinely free in the happy path: when the condition is truthy, the entry is never built and channels are never invoked.
1248
-
1249
- ```ts
1250
- log.assert(user !== null, "auth", "session", "user vanished mid-flight", {
1251
- sessionId,
1252
- });
1253
-
1254
- // truthy → no log call
1255
- // falsy → equivalent to log.error("auth", "session", "user vanished...", { sessionId })
1256
- ```
1257
-
1258
- The level is implicitly `error` — assertions express failures, not warnings. If you need a non-error level, use `log.error` / `log.warn` directly with your own `if`.
1259
-
1260
- ### Why not `console.assert`?
1261
-
1262
- `console.assert` writes to stderr only and bypasses your file/JSON channels. `log.assert` runs through the logger pipeline, so a failed assertion is captured by every persistent channel you've configured. See [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md).
1263
-
1264
- ## `log.timer(module, action)`
1265
-
1266
- Returns an end-function. Calling it emits an `info` entry with `completed in <ms>ms` and a `durationMs` field in `context`.
1267
-
1268
- ```ts
1269
- const end = log.timer("db", "users.findById");
1270
- const user = await usersRepo.findById(id);
1271
- end({ id, found: !!user });
1272
- // ℹ info [db] [users.findById] completed in 12ms
1273
- // ↳ { durationMs: 12, id: "abc", found: true } (when ConsoleLog has showContext: true)
1274
- ```
1275
-
1276
- Common patterns:
1277
-
1278
- ```ts
1279
- // Around an HTTP handler
1280
- async function handle(req) {
1281
- const end = log.timer("http", `${req.method} ${req.url}`);
1282
- try {
1283
- return await runHandler(req);
1284
- } finally {
1285
- end({ status: res.statusCode });
1286
- }
1287
- }
1288
-
1289
- // Around a job
1290
- const end = log.timer("jobs", "nightly-report");
1291
- await report.run();
1292
- end({ rowsProcessed: report.rowCount });
1293
- ```
1294
-
1295
- `end()` can be called more than once if you want intermediate checkpoints — each call emits a fresh entry with the duration measured from the original `timer()` call.
1296
-
1297
- ### Caveats
1298
-
1299
- - The duration is `Date.now()` based — millisecond resolution. For sub-millisecond profiling, reach for `performance.now()` directly.
1300
- - The end-function captures `this` at construction; calling it after the logger is reconfigured still routes through the same `Logger` instance.
1301
- - `log.timer` shorthand binds to the singleton — see [`@warlock.js/logger/test-logging-code/SKILL.md`](@warlock.js/logger/test-logging-code/SKILL.md) for how to swap channels per test.
1302
-
1303
-
1304
- ## write-custom-log-channel `@warlock.js/logger/write-custom-log-channel/SKILL.md`
1305
-
1067
+
1068
+
1069
+ ## test-logging-code `@warlock.js/logger/test-logging-code/SKILL.md`
1070
+
1071
+ ---
1072
+ name: test-logging-code
1073
+ description: 'Test code that touches the logger — silence globally via log.setChannels([]) in setupFiles, assert specific log lines via a capturing LogChannel subclass (prefer it over vi.spyOn — it asserts on delivered entries, not just method calls, and isolates the shared singleton cleanly). Triggers: `log.setChannels`, `LogChannel`, `LoggingData`, `Logger`, `log.channels`; "silence logger in vitest", "assert a log line was emitted", "capture log output in tests", "test code that logs"; typical import `import { log, Logger, LogChannel, type LoggingData } from "@warlock.js/logger"`. Skip: custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `vi.spyOn(console)`, `jest.spyOn`.'
1074
+ ---
1075
+
1076
+ # Testing — code that logs, and asserting on log output
1077
+
1078
+ Two scenarios: **silencing the logger during tests** (most common) and **asserting that a specific log line was emitted**.
1079
+
1080
+ ## Silence the logger during tests
1081
+
1082
+ Clear every channel once, globally. No output, no file handles, no noise.
1083
+
1084
+ ```ts title="src/setupTests.ts"
1085
+ import { log } from "@warlock.js/logger";
1086
+
1087
+ log.setChannels([]);
1088
+ ```
1089
+
1090
+ Wire it in Vitest:
1091
+
1092
+ ```ts title="vitest.config.ts"
1093
+ import { defineConfig } from "vitest/config";
1094
+
1095
+ export default defineConfig({
1096
+ test: {
1097
+ setupFiles: ["src/setupTests.ts"],
1098
+ },
1099
+ });
1100
+ ```
1101
+
1102
+ ## Assert on log output — use a capturing channel
1103
+
1104
+ Don't spy on `console.log` and don't mock `log.info` — assert on what a channel actually received instead (see "Why not spy on `log.info`?" below). The cleanest pattern is a tiny channel that records what it sees:
1105
+
1106
+ ```ts
1107
+ import { LogChannel } from "@warlock.js/logger";
1108
+ import type { LoggingData } from "@warlock.js/logger";
1109
+
1110
+ class CapturingChannel extends LogChannel {
1111
+ public name = "capture";
1112
+ public terminal = false;
1113
+ public received: LoggingData[] = [];
1114
+ public log(data: LoggingData) { this.received.push({ ...data }); }
1115
+ }
1116
+ ```
1117
+
1118
+ ### Test against the singleton
1119
+
1120
+ ```ts
1121
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
1122
+ import { log } from "@warlock.js/logger";
1123
+ import { createUser } from "./users";
1124
+
1125
+ describe("createUser", () => {
1126
+ let capture: CapturingChannel;
1127
+ let originalChannels: typeof log.channels;
1128
+
1129
+ beforeEach(() => {
1130
+ capture = new CapturingChannel();
1131
+ originalChannels = log.channels;
1132
+ log.channels = [capture];
1133
+ });
1134
+
1135
+ afterEach(() => {
1136
+ log.channels = originalChannels;
1137
+ });
1138
+
1139
+ it("logs a success entry when the user is created", async () => {
1140
+ await createUser({ email: "a@b.com" });
1141
+
1142
+ expect(capture.received).toContainEqual(
1143
+ expect.objectContaining({
1144
+ type: "success",
1145
+ module: "users",
1146
+ action: "create",
1147
+ }),
1148
+ );
1149
+ });
1150
+ });
1151
+ ```
1152
+
1153
+ ### Test an isolated logger (avoid touching the singleton)
1154
+
1155
+ If the code under test accepts a logger via injection, create one per test:
1156
+
1157
+ ```ts
1158
+ import { Logger } from "@warlock.js/logger";
1159
+
1160
+ const testLogger = new Logger();
1161
+ const capture = new CapturingChannel();
1162
+ testLogger.addChannel(capture);
1163
+
1164
+ await createUser({ email: "a@b.com" }, testLogger);
1165
+
1166
+ expect(capture.received[0]!.type).toBe("success");
1167
+ ```
1168
+
1169
+ No cleanup needed — the local `Logger` is garbage-collected.
1170
+
1171
+ ## Why not spy on `log.info`?
1172
+
1173
+ `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:
1174
+
1175
+ - 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.
1176
+ - 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.
1177
+ - Code that logs through `log.error(...)` and the bare object form `log.log({ type, ... })` both land in channels, but only the level shortcut goes through `log.info` — a channel catches both.
1178
+
1179
+ So capture through a channel as shown above; reach for a method spy only when you specifically want to assert "this exact shortcut was invoked".
1180
+
1181
+ ## Testing a custom channel
1182
+
1183
+ Write specs against the channel directly; don't route through `Logger`:
1184
+
1185
+ ```ts
1186
+ import { describe, it, expect, vi } from "vitest";
1187
+ import { SlackLog } from "./slack-log";
1188
+
1189
+ describe("SlackLog", () => {
1190
+ it("skips non-error levels", async () => {
1191
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response());
1192
+
1193
+ const channel = new SlackLog({ webhookUrl: "https://test", levels: ["error"] });
1194
+ await channel.log({ type: "info", module: "x", action: "y", message: "z" });
1195
+
1196
+ expect(fetchSpy).not.toHaveBeenCalled();
1197
+ });
1198
+ });
1199
+ ```
1200
+
1201
+ ## Testing `FileLog` and `JSONFileLog`
1202
+
1203
+ Use real temp directories — it's the only way to exercise file IO, rotation, chunking, and JSON I/O with fidelity:
1204
+
1205
+ ```ts
1206
+ import fs from "fs";
1207
+ import os from "os";
1208
+ import path from "path";
1209
+ import { randomUUID } from "node:crypto";
1210
+
1211
+ function tempDir() {
1212
+ const dir = path.join(os.tmpdir(), "logger-test", randomUUID());
1213
+ fs.mkdirSync(dir, { recursive: true });
1214
+ return dir;
1215
+ }
1216
+ ```
1217
+
1218
+ Clean up in `afterEach(() => fs.rmSync(dir, { recursive: true, force: true }))`.
1219
+
1220
+ ## Waiting for async init
1221
+
1222
+ `LogChannel.init()` runs inside a `setTimeout(0)`. Before asserting on post-init behavior, yield once:
1223
+
1224
+ ```ts
1225
+ const channel = new FileLog({ storagePath: tempDir() });
1226
+ await new Promise((r) => setTimeout(r, 10));
1227
+ // Now `channel.isInitialized` is true and it's safe to call `channel.log(...)` for real I/O.
1228
+ ```
1229
+
1230
+ ## Testing `captureAnyUnhandledRejection`
1231
+
1232
+ Don't actually throw unhandled rejections in tests — emit the listener directly:
1233
+
1234
+ ```ts
1235
+ captureAnyUnhandledRejection();
1236
+ process.emit("unhandledRejection", new Error("test"), Promise.resolve());
1237
+ ```
1238
+
1239
+ The `uncaughtException` path additionally calls `process.exit(1)`, so stub it (or pass `{ exitOnUncaughtException: false }`) before emitting — otherwise the emitted exception tears the test runner down:
1240
+
1241
+ ```ts
1242
+ vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
1243
+ captureAnyUnhandledRejection();
1244
+ process.emit("uncaughtException", new Error("test"), "uncaughtException");
1245
+ ```
1246
+
1247
+ See [`@warlock.js/logger/capture-unhandled-errors/SKILL.md`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) for a full example.
1248
+
1249
+
1250
+ ## use-log-helpers `@warlock.js/logger/use-log-helpers/SKILL.md`
1251
+
1252
+ ---
1253
+ name: use-log-helpers
1254
+ description: 'Two DX shortcuts on every Logger — log.assert(condition, module, action, message, context?) logs an error when condition is falsy (free on the happy path), log.timer(module, action) returns an end-function emitting an info entry with measured duration. Triggers: `log.assert`, `log.timer`, `durationMs`; "assert an invariant via logger", "measure how long an operation took", "time a request", "log operation duration"; typical import `import { log } from "@warlock.js/logger"`. Skip: basics — `@warlock.js/logger/logger-basics/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `console.assert`, `console.time`, `console.timeEnd`, `perf_hooks.performance.now`.'
1255
+ ---
1256
+
1257
+ # Helpers — `assert`, `timer`
1258
+
1259
+ Two small DX shortcuts on every `Logger` (and the bound `log` helper). They route through the normal log pipeline — every channel sees what they emit.
1260
+
1261
+ ## `log.assert(condition, module, action, message, context?)`
1262
+
1263
+ Logs an `error` entry when `condition` is falsy. Genuinely free in the happy path: when the condition is truthy, the entry is never built and channels are never invoked.
1264
+
1265
+ ```ts
1266
+ log.assert(user !== null, "auth", "session", "user vanished mid-flight", {
1267
+ sessionId,
1268
+ });
1269
+
1270
+ // truthy no log call
1271
+ // falsy → equivalent to log.error("auth", "session", "user vanished...", { sessionId })
1272
+ ```
1273
+
1274
+ The level is implicitly `error` assertions express failures, not warnings. If you need a non-error level, use `log.error` / `log.warn` directly with your own `if`.
1275
+
1276
+ ### Why not `console.assert`?
1277
+
1278
+ `console.assert` writes to stderr only and bypasses your file/JSON channels. `log.assert` runs through the logger pipeline, so a failed assertion is captured by every persistent channel you've configured. See [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md).
1279
+
1280
+ ## `log.timer(module, action)`
1281
+
1282
+ Returns an end-function. Calling it emits an `info` entry with `completed in <ms>ms` and a `durationMs` field in `context`.
1283
+
1284
+ ```ts
1285
+ const end = log.timer("db", "users.findById");
1286
+ const user = await usersRepo.findById(id);
1287
+ end({ id, found: !!user });
1288
+ // info [db] [users.findById] completed in 12ms
1289
+ // ↳ { durationMs: 12, id: "abc", found: true } (when ConsoleLog has showContext: true)
1290
+ ```
1291
+
1292
+ Common patterns:
1293
+
1294
+ ```ts
1295
+ // Around an HTTP handler
1296
+ async function handle(req) {
1297
+ const end = log.timer("http", `${req.method} ${req.url}`);
1298
+ try {
1299
+ return await runHandler(req);
1300
+ } finally {
1301
+ end({ status: res.statusCode });
1302
+ }
1303
+ }
1304
+
1305
+ // Around a job
1306
+ const end = log.timer("jobs", "nightly-report");
1307
+ await report.run();
1308
+ end({ rowsProcessed: report.rowCount });
1309
+ ```
1310
+
1311
+ `end()` can be called more than once if you want intermediate checkpoints — each call emits a fresh entry with the duration measured from the original `timer()` call.
1312
+
1313
+ ### Caveats
1314
+
1315
+ - The duration is `Date.now()` based — millisecond resolution. For sub-millisecond profiling, reach for `performance.now()` directly.
1316
+ - The end-function captures `this` at construction; calling it after the logger is reconfigured still routes through the same `Logger` instance.
1317
+ - `log.timer` shorthand binds to the singleton — see [`@warlock.js/logger/test-logging-code/SKILL.md`](@warlock.js/logger/test-logging-code/SKILL.md) for how to swap channels per test.
1318
+
1319
+
1320
+ ## write-custom-log-channel `@warlock.js/logger/write-custom-log-channel/SKILL.md`
1321
+
1306
1322
  ---
1307
1323
  name: write-custom-log-channel
1308
1324
  description: 'Extend the abstract LogChannel class for custom sinks — Slack, database, HTTP endpoint, in-memory buffer. Triggers: `LogChannel`, `LogContract`, `LoggingData`, `shouldBeLogged`, `init`, `flush`, `flushSync`, `terminal`; "log to slack", "log to a database", "send logs to datadog / loki HTTP api", "in-memory test capture channel", "build a custom log sink"; typical import `import { LogChannel, type LoggingData, type LogContract } from "@warlock.js/logger"`. Skip: built-in channels — `@warlock.js/logger/pick-log-channel/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing libs `winston-transport`, `pino-transport`.'
@@ -1493,5 +1509,5 @@ Prefer extending `LogChannel` unless you have a concrete reason not to — the l
1493
1509
  - Don't throw synchronously from `log()`. The logger fires it without awaiting; an unhandled rejection takes down the process (unless `captureAnyUnhandledRejection` is wired up — and then it's embarrassing to be the cause).
1494
1510
  - Don't block the event loop. `log()` may be sync or async; if your work takes >100ms, make it async and return the promise.
1495
1511
  - Don't forget `shouldBeLogged(data)` at the top of `log()` — or your channel silently ignores `levels` / `filter` config.
1496
-
1497
-
1512
+
1513
+