@warlock.js/logger 5.19.1 → 5.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ---
10
10
  name: capture-unhandled-errors
11
- description: "Error capture — routing Node's unhandled errors through the logger in @warlock.js/logger; use when you need to capture unhandled errors."
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
@@ -121,118 +121,118 @@ If you want these routed to a specific file, filter on `data.module === "app"`.
121
121
 
122
122
  ## configure-logger `@warlock.js/logger/configure-logger/SKILL.md`
123
123
 
124
- ---
125
- name: configure-logger
126
- description: "Setup — registering channels at startup in @warlock.js/logger; use when you need to configure logger."
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
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
229
 
230
230
 
231
231
  ## filter-log-entries `@warlock.js/logger/filter-log-entries/SKILL.md`
232
232
 
233
233
  ---
234
234
  name: filter-log-entries
235
- description: "Filtering — levels + filter predicate + minLevel in @warlock.js/logger; use when you need to filter log entries."
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.'
236
236
  ---
237
237
 
238
238
  # Filtering — `levels` + `filter` predicate + `minLevel`
@@ -356,7 +356,7 @@ The `minLevel` check is the fastest of the three (single comparison before fan-o
356
356
 
357
357
  ---
358
358
  name: flush-logs-on-shutdown
359
- description: "Lifecycle — flushing buffered channels before exit in @warlock.js/logger; use when you need to 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 — `@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'')`.'
360
360
  ---
361
361
 
362
362
  # Lifecycle — flushing buffered channels before exit
@@ -667,7 +667,7 @@ Silence the logger globally in tests via `log.setChannels([])` in `setupFiles`.
667
667
 
668
668
  ---
669
669
  name: pick-log-channel
670
- description: "Channels — which one to pick and how to configure it in @warlock.js/logger; use when you need to choose log channel."
670
+ 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`.'
671
671
  ---
672
672
 
673
673
  # Channels — which one to pick and how to configure it
@@ -824,169 +824,169 @@ If two channels share a `name`, only one is reachable this way — the search re
824
824
 
825
825
  ## redact-sensitive-log-fields `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`
826
826
 
827
- ---
828
- name: redact-sensitive-log-fields
829
- description: "Redaction — keeping secrets out of logs in @warlock.js/logger; use when you need to redact sensitive log fields."
830
- ---
831
-
832
- # Redaction — keeping secrets out of logs
833
-
834
- Three layers: a **built-in key denylist that is on by default**, plus opt-in path globs configured at the logger and/or per channel.
835
-
836
- ## Layer 0 — the default denylist (since 4.15.0, no configuration needed)
837
-
838
- Common secret **key names** are censored at any depth of `context`, `message`, and an `Error`'s own enumerable properties — before any of your config runs:
839
-
840
- ```ts
841
- log.error("auth", "login", "failed", { headers: req.headers, body: req.body });
842
- // context.headers.authorization → "[REDACTED]"
843
- // context.body.password → "[REDACTED]"
844
- // context.body.email → untouched
845
- ```
846
-
847
- Keys are matched **exactly**, on a normalized form (lower-cased, separators stripped) — so one entry covers `apiKey` / `api_key` / `API-KEY` / `x-api-key`. It is not substring matching: `tokenCount` and `passwordUpdatedAt` survive. Read the exact set from `DEFAULT_REDACT_KEYS`.
848
-
849
- ```ts
850
- import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
851
-
852
- log.configure({
853
- redact: {
854
- keys: ["internalRef"], // union with the built-in set
855
- defaultKeys: false, // opt out of the built-in set entirely
856
- },
857
- });
858
- ```
859
-
860
- `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
-
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.
863
-
864
- 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
-
866
- ## Layers 1 & 2 — path globs, opt-in
867
-
868
- For anything the denylist can't name by key — a secret under an app-specific key, or a value you want partially masked rather than blanked.
869
-
870
- The model in one line:
871
-
872
- > Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
873
-
874
- 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.
875
-
876
- ## Logger-wide floor
877
-
878
- ```ts
879
- import { log } from "@warlock.js/logger";
880
-
881
- log.configure({
882
- redact: {
883
- paths: [
884
- "context.password",
885
- "context.*.token",
886
- "context.headers.authorization",
887
- ],
888
- censor: "[REDACTED]", // default — string or function
889
- },
890
- });
891
-
892
- // runtime equivalent:
893
- log.setRedact({ paths: ["context.password"] });
894
- log.setRedact(undefined); // clear your paths (the default denylist stays on)
895
- ```
896
-
897
- Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
898
-
899
- ## Per-channel additive
900
-
901
- ```ts
902
- new SlackChannel({
903
- webhook: "...",
904
- redact: {
905
- paths: ["context.user.email", "context.metadata.*"],
906
- // censor inherited from logger-wide when omitted
907
- },
908
- });
909
- ```
910
-
911
- 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.
912
-
913
- ### When to set redact per-channel
914
-
915
- - Loud destinations with broader audiences (Slack, Discord, error trackers, anything off your machine) — redact more aggressively.
916
- - Local-only destinations (FileLog you alone read, the dev terminal) — keep the floor minimal so you can debug.
917
-
918
- ### When NOT to set it
919
-
920
- 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.
921
-
922
- ## Path syntax
923
-
924
- Paths are dotted glob patterns evaluated against the full `LoggingData`:
925
-
926
- ```
927
- type LoggingData = {
928
- type: "info" | ...,
929
- module: string,
930
- action: string,
931
- message: any, // ← prefix paths with "message." to redact here
932
- context?: object, // ← prefix paths with "context." to redact here
933
- };
934
- ```
935
-
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) |
943
-
944
- Wildcards:
945
-
946
- - `*` — exactly one segment (any object key, any array index).
947
- - `**` — zero or more segments, greedily; matches at any depth.
948
-
949
- ## Censor variants
950
-
951
- ```ts
952
- // String — replace with a literal.
953
- { censor: "[REDACTED]" }
954
- { censor: "***" }
955
-
956
- // Function — receives original value + dotted path, returns the replacement.
957
- {
958
- censor: (value, path) => {
959
- if (typeof value !== "string") return "[REDACTED]";
960
- return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***";
961
- },
962
- }
963
- ```
964
-
965
- 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).
966
-
967
- ## Immutability
968
-
969
- `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.
970
-
971
- ## What about the `message` field?
972
-
973
- 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.
974
-
975
- ## Performance notes
976
-
977
- - **No redact configured** → the default denylist still runs (since 4.15.0): a cheap presence scan for a denylisted key on every `log()` call, with the deep clone + censor pass skipped entirely when nothing matches. Only `{ defaultKeys: false }` (no `paths`, no extra `keys`) gets back to zero work.
978
- - **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
979
- - **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.
980
- - Each path is matched independently; cost grows linearly with `paths.length`.
981
-
982
- 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.
827
+ ---
828
+ 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 — `@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`.'
830
+ ---
831
+
832
+ # Redaction — keeping secrets out of logs
833
+
834
+ Three layers: a **built-in key denylist that is on by default**, plus opt-in path globs configured at the logger and/or per channel.
835
+
836
+ ## Layer 0 — the default denylist (since 4.15.0, no configuration needed)
837
+
838
+ Common secret **key names** are censored at any depth of `context`, `message`, and an `Error`'s own enumerable properties — before any of your config runs:
839
+
840
+ ```ts
841
+ log.error("auth", "login", "failed", { headers: req.headers, body: req.body });
842
+ // context.headers.authorization → "[REDACTED]"
843
+ // context.body.password → "[REDACTED]"
844
+ // context.body.email → untouched
845
+ ```
846
+
847
+ Keys are matched **exactly**, on a normalized form (lower-cased, separators stripped) — so one entry covers `apiKey` / `api_key` / `API-KEY` / `x-api-key`. It is not substring matching: `tokenCount` and `passwordUpdatedAt` survive. Read the exact set from `DEFAULT_REDACT_KEYS`.
848
+
849
+ ```ts
850
+ import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
851
+
852
+ log.configure({
853
+ redact: {
854
+ keys: ["internalRef"], // union with the built-in set
855
+ defaultKeys: false, // opt out of the built-in set entirely
856
+ },
857
+ });
858
+ ```
859
+
860
+ `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
+
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.
863
+
864
+ 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
+
866
+ ## Layers 1 & 2 — path globs, opt-in
867
+
868
+ For anything the denylist can't name by key — a secret under an app-specific key, or a value you want partially masked rather than blanked.
869
+
870
+ The model in one line:
871
+
872
+ > Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
873
+
874
+ 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.
875
+
876
+ ## Logger-wide floor
877
+
878
+ ```ts
879
+ import { log } from "@warlock.js/logger";
880
+
881
+ log.configure({
882
+ redact: {
883
+ paths: [
884
+ "context.password",
885
+ "context.*.token",
886
+ "context.headers.authorization",
887
+ ],
888
+ censor: "[REDACTED]", // default — string or function
889
+ },
890
+ });
891
+
892
+ // runtime equivalent:
893
+ log.setRedact({ paths: ["context.password"] });
894
+ log.setRedact(undefined); // clear your paths (the default denylist stays on)
895
+ ```
896
+
897
+ Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
898
+
899
+ ## Per-channel additive
900
+
901
+ ```ts
902
+ new SlackChannel({
903
+ webhook: "...",
904
+ redact: {
905
+ paths: ["context.user.email", "context.metadata.*"],
906
+ // censor inherited from logger-wide when omitted
907
+ },
908
+ });
909
+ ```
910
+
911
+ 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.
912
+
913
+ ### When to set redact per-channel
914
+
915
+ - Loud destinations with broader audiences (Slack, Discord, error trackers, anything off your machine) — redact more aggressively.
916
+ - Local-only destinations (FileLog you alone read, the dev terminal) — keep the floor minimal so you can debug.
917
+
918
+ ### When NOT to set it
919
+
920
+ 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.
921
+
922
+ ## Path syntax
923
+
924
+ Paths are dotted glob patterns evaluated against the full `LoggingData`:
925
+
926
+ ```
927
+ type LoggingData = {
928
+ type: "info" | ...,
929
+ module: string,
930
+ action: string,
931
+ message: any, // ← prefix paths with "message." to redact here
932
+ context?: object, // ← prefix paths with "context." to redact here
933
+ };
934
+ ```
935
+
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) |
943
+
944
+ Wildcards:
945
+
946
+ - `*` — exactly one segment (any object key, any array index).
947
+ - `**` — zero or more segments, greedily; matches at any depth.
948
+
949
+ ## Censor variants
950
+
951
+ ```ts
952
+ // String — replace with a literal.
953
+ { censor: "[REDACTED]" }
954
+ { censor: "***" }
955
+
956
+ // Function — receives original value + dotted path, returns the replacement.
957
+ {
958
+ censor: (value, path) => {
959
+ if (typeof value !== "string") return "[REDACTED]";
960
+ return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***";
961
+ },
962
+ }
963
+ ```
964
+
965
+ 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).
966
+
967
+ ## Immutability
968
+
969
+ `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.
970
+
971
+ ## What about the `message` field?
972
+
973
+ 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.
974
+
975
+ ## Performance notes
976
+
977
+ - **No redact configured** → the default denylist still runs (since 4.15.0): a cheap presence scan for a denylisted key on every `log()` call, with the deep clone + censor pass skipped entirely when nothing matches. Only `{ defaultKeys: false }` (no `paths`, no extra `keys`) gets back to zero work.
978
+ - **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
979
+ - **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.
980
+ - Each path is matched independently; cost grows linearly with `paths.length`.
981
+
982
+ 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
983
 
984
984
 
985
985
  ## ship-logs-to-sentry `@warlock.js/logger/ship-logs-to-sentry/SKILL.md`
986
986
 
987
987
  ---
988
988
  name: ship-logs-to-sentry
989
- description: "Ship logs to Sentry — the SentryLog channel in @warlock.js/logger; use when you need to ship logs to sentry."
989
+ 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.'
990
990
  ---
991
991
 
992
992
  # Ship logs to Sentry — the `SentryLog` channel
@@ -1106,260 +1106,260 @@ The channel never crashes your app: the dynamic import failure is swallowed, the
1106
1106
 
1107
1107
  ## test-logging-code `@warlock.js/logger/test-logging-code/SKILL.md`
1108
1108
 
1109
- ---
1110
- name: test-logging-code
1111
- description: "Testing — code that logs, and asserting on log output in @warlock.js/logger; use when you need to test logging code."
1112
- ---
1113
-
1114
- # Testing — code that logs, and asserting on log output
1115
-
1116
- Two scenarios: **silencing the logger during tests** (most common) and **asserting that a specific log line was emitted**.
1117
-
1118
- ## Silence the logger during tests
1119
-
1120
- Clear every channel once, globally. No output, no file handles, no noise.
1121
-
1122
- ```ts title="src/setupTests.ts"
1123
- import { log } from "@warlock.js/logger";
1124
-
1125
- log.setChannels([]);
1126
- ```
1127
-
1128
- Wire it in Vitest:
1129
-
1130
- ```ts title="vitest.config.ts"
1131
- import { defineConfig } from "vitest/config";
1132
-
1133
- export default defineConfig({
1134
- test: {
1135
- setupFiles: ["src/setupTests.ts"],
1136
- },
1137
- });
1138
- ```
1139
-
1140
- ## Assert on log output — use a capturing channel
1141
-
1142
- 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:
1143
-
1144
- ```ts
1145
- import { LogChannel } from "@warlock.js/logger";
1146
- import type { LoggingData } from "@warlock.js/logger";
1147
-
1148
- class CapturingChannel extends LogChannel {
1149
- public name = "capture";
1150
- public terminal = false;
1151
- public received: LoggingData[] = [];
1152
- public log(data: LoggingData) { this.received.push({ ...data }); }
1153
- }
1154
- ```
1155
-
1156
- ### Test against the singleton
1157
-
1158
- ```ts
1159
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
1160
- import { log } from "@warlock.js/logger";
1161
- import { createUser } from "./users";
1162
-
1163
- describe("createUser", () => {
1164
- let capture: CapturingChannel;
1165
- let originalChannels: typeof log.channels;
1166
-
1167
- beforeEach(() => {
1168
- capture = new CapturingChannel();
1169
- originalChannels = log.channels;
1170
- log.channels = [capture];
1171
- });
1172
-
1173
- afterEach(() => {
1174
- log.channels = originalChannels;
1175
- });
1176
-
1177
- it("logs a success entry when the user is created", async () => {
1178
- await createUser({ email: "a@b.com" });
1179
-
1180
- expect(capture.received).toContainEqual(
1181
- expect.objectContaining({
1182
- type: "success",
1183
- module: "users",
1184
- action: "create",
1185
- }),
1186
- );
1187
- });
1188
- });
1189
- ```
1190
-
1191
- ### Test an isolated logger (avoid touching the singleton)
1192
-
1193
- If the code under test accepts a logger via injection, create one per test:
1194
-
1195
- ```ts
1196
- import { Logger } from "@warlock.js/logger";
1197
-
1198
- const testLogger = new Logger();
1199
- const capture = new CapturingChannel();
1200
- testLogger.addChannel(capture);
1201
-
1202
- await createUser({ email: "a@b.com" }, testLogger);
1203
-
1204
- expect(capture.received[0]!.type).toBe("success");
1205
- ```
1206
-
1207
- No cleanup needed — the local `Logger` is garbage-collected.
1208
-
1209
- ## Why not spy on `log.info`?
1210
-
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:
1212
-
1213
- - 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
- - 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.
1215
- - 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.
1216
-
1217
- 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".
1218
-
1219
- ## Testing a custom channel
1220
-
1221
- Write specs against the channel directly; don't route through `Logger`:
1222
-
1223
- ```ts
1224
- import { describe, it, expect, vi } from "vitest";
1225
- import { SlackLog } from "./slack-log";
1226
-
1227
- describe("SlackLog", () => {
1228
- it("skips non-error levels", async () => {
1229
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response());
1230
-
1231
- const channel = new SlackLog({ webhookUrl: "https://test", levels: ["error"] });
1232
- await channel.log({ type: "info", module: "x", action: "y", message: "z" });
1233
-
1234
- expect(fetchSpy).not.toHaveBeenCalled();
1235
- });
1236
- });
1237
- ```
1238
-
1239
- ## Testing `FileLog` and `JSONFileLog`
1240
-
1241
- Use real temp directories — it's the only way to exercise file IO, rotation, chunking, and JSON I/O with fidelity:
1242
-
1243
- ```ts
1244
- import fs from "fs";
1245
- import os from "os";
1246
- import path from "path";
1247
- import { randomUUID } from "node:crypto";
1248
-
1249
- function tempDir() {
1250
- const dir = path.join(os.tmpdir(), "logger-test", randomUUID());
1251
- fs.mkdirSync(dir, { recursive: true });
1252
- return dir;
1253
- }
1254
- ```
1255
-
1256
- Clean up in `afterEach(() => fs.rmSync(dir, { recursive: true, force: true }))`.
1257
-
1258
- ## Waiting for async init
1259
-
1260
- `LogChannel.init()` runs inside a `setTimeout(0)`. Before asserting on post-init behavior, yield once:
1261
-
1262
- ```ts
1263
- const channel = new FileLog({ storagePath: tempDir() });
1264
- await new Promise((r) => setTimeout(r, 10));
1265
- // Now `channel.isInitialized` is true and it's safe to call `channel.log(...)` for real I/O.
1266
- ```
1267
-
1268
- ## Testing `captureAnyUnhandledRejection`
1269
-
1270
- Don't actually throw unhandled rejections in tests — emit the listener directly:
1271
-
1272
- ```ts
1273
- captureAnyUnhandledRejection();
1274
- process.emit("unhandledRejection", new Error("test"), Promise.resolve());
1275
- ```
1276
-
1277
- 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:
1278
-
1279
- ```ts
1280
- vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
1281
- captureAnyUnhandledRejection();
1282
- process.emit("uncaughtException", new Error("test"), "uncaughtException");
1283
- ```
1284
-
1285
- See [`@warlock.js/logger/capture-unhandled-errors/SKILL.md`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) for a full example.
1109
+ ---
1110
+ name: test-logging-code
1111
+ 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`.'
1112
+ ---
1113
+
1114
+ # Testing — code that logs, and asserting on log output
1115
+
1116
+ Two scenarios: **silencing the logger during tests** (most common) and **asserting that a specific log line was emitted**.
1117
+
1118
+ ## Silence the logger during tests
1119
+
1120
+ Clear every channel once, globally. No output, no file handles, no noise.
1121
+
1122
+ ```ts title="src/setupTests.ts"
1123
+ import { log } from "@warlock.js/logger";
1124
+
1125
+ log.setChannels([]);
1126
+ ```
1127
+
1128
+ Wire it in Vitest:
1129
+
1130
+ ```ts title="vitest.config.ts"
1131
+ import { defineConfig } from "vitest/config";
1132
+
1133
+ export default defineConfig({
1134
+ test: {
1135
+ setupFiles: ["src/setupTests.ts"],
1136
+ },
1137
+ });
1138
+ ```
1139
+
1140
+ ## Assert on log output — use a capturing channel
1141
+
1142
+ 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:
1143
+
1144
+ ```ts
1145
+ import { LogChannel } from "@warlock.js/logger";
1146
+ import type { LoggingData } from "@warlock.js/logger";
1147
+
1148
+ class CapturingChannel extends LogChannel {
1149
+ public name = "capture";
1150
+ public terminal = false;
1151
+ public received: LoggingData[] = [];
1152
+ public log(data: LoggingData) { this.received.push({ ...data }); }
1153
+ }
1154
+ ```
1155
+
1156
+ ### Test against the singleton
1157
+
1158
+ ```ts
1159
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
1160
+ import { log } from "@warlock.js/logger";
1161
+ import { createUser } from "./users";
1162
+
1163
+ describe("createUser", () => {
1164
+ let capture: CapturingChannel;
1165
+ let originalChannels: typeof log.channels;
1166
+
1167
+ beforeEach(() => {
1168
+ capture = new CapturingChannel();
1169
+ originalChannels = log.channels;
1170
+ log.channels = [capture];
1171
+ });
1172
+
1173
+ afterEach(() => {
1174
+ log.channels = originalChannels;
1175
+ });
1176
+
1177
+ it("logs a success entry when the user is created", async () => {
1178
+ await createUser({ email: "a@b.com" });
1179
+
1180
+ expect(capture.received).toContainEqual(
1181
+ expect.objectContaining({
1182
+ type: "success",
1183
+ module: "users",
1184
+ action: "create",
1185
+ }),
1186
+ );
1187
+ });
1188
+ });
1189
+ ```
1190
+
1191
+ ### Test an isolated logger (avoid touching the singleton)
1192
+
1193
+ If the code under test accepts a logger via injection, create one per test:
1194
+
1195
+ ```ts
1196
+ import { Logger } from "@warlock.js/logger";
1197
+
1198
+ const testLogger = new Logger();
1199
+ const capture = new CapturingChannel();
1200
+ testLogger.addChannel(capture);
1201
+
1202
+ await createUser({ email: "a@b.com" }, testLogger);
1203
+
1204
+ expect(capture.received[0]!.type).toBe("success");
1205
+ ```
1206
+
1207
+ No cleanup needed — the local `Logger` is garbage-collected.
1208
+
1209
+ ## Why not spy on `log.info`?
1210
+
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:
1212
+
1213
+ - 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
+ - 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.
1215
+ - 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.
1216
+
1217
+ 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".
1218
+
1219
+ ## Testing a custom channel
1220
+
1221
+ Write specs against the channel directly; don't route through `Logger`:
1222
+
1223
+ ```ts
1224
+ import { describe, it, expect, vi } from "vitest";
1225
+ import { SlackLog } from "./slack-log";
1226
+
1227
+ describe("SlackLog", () => {
1228
+ it("skips non-error levels", async () => {
1229
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response());
1230
+
1231
+ const channel = new SlackLog({ webhookUrl: "https://test", levels: ["error"] });
1232
+ await channel.log({ type: "info", module: "x", action: "y", message: "z" });
1233
+
1234
+ expect(fetchSpy).not.toHaveBeenCalled();
1235
+ });
1236
+ });
1237
+ ```
1238
+
1239
+ ## Testing `FileLog` and `JSONFileLog`
1240
+
1241
+ Use real temp directories — it's the only way to exercise file IO, rotation, chunking, and JSON I/O with fidelity:
1242
+
1243
+ ```ts
1244
+ import fs from "fs";
1245
+ import os from "os";
1246
+ import path from "path";
1247
+ import { randomUUID } from "node:crypto";
1248
+
1249
+ function tempDir() {
1250
+ const dir = path.join(os.tmpdir(), "logger-test", randomUUID());
1251
+ fs.mkdirSync(dir, { recursive: true });
1252
+ return dir;
1253
+ }
1254
+ ```
1255
+
1256
+ Clean up in `afterEach(() => fs.rmSync(dir, { recursive: true, force: true }))`.
1257
+
1258
+ ## Waiting for async init
1259
+
1260
+ `LogChannel.init()` runs inside a `setTimeout(0)`. Before asserting on post-init behavior, yield once:
1261
+
1262
+ ```ts
1263
+ const channel = new FileLog({ storagePath: tempDir() });
1264
+ await new Promise((r) => setTimeout(r, 10));
1265
+ // Now `channel.isInitialized` is true and it's safe to call `channel.log(...)` for real I/O.
1266
+ ```
1267
+
1268
+ ## Testing `captureAnyUnhandledRejection`
1269
+
1270
+ Don't actually throw unhandled rejections in tests — emit the listener directly:
1271
+
1272
+ ```ts
1273
+ captureAnyUnhandledRejection();
1274
+ process.emit("unhandledRejection", new Error("test"), Promise.resolve());
1275
+ ```
1276
+
1277
+ 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:
1278
+
1279
+ ```ts
1280
+ vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
1281
+ captureAnyUnhandledRejection();
1282
+ process.emit("uncaughtException", new Error("test"), "uncaughtException");
1283
+ ```
1284
+
1285
+ See [`@warlock.js/logger/capture-unhandled-errors/SKILL.md`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) for a full example.
1286
1286
 
1287
1287
 
1288
1288
  ## use-log-helpers `@warlock.js/logger/use-log-helpers/SKILL.md`
1289
1289
 
1290
- ---
1291
- name: use-log-helpers
1292
- description: "Helpers — assert, timer in @warlock.js/logger; use when you need to use log helpers."
1293
- ---
1294
-
1295
- # Helpers — `assert`, `timer`
1296
-
1297
- 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.
1298
-
1299
- ## `log.assert(condition, module, action, message, context?)`
1300
-
1301
- 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.
1302
-
1303
- ```ts
1304
- log.assert(user !== null, "auth", "session", "user vanished mid-flight", {
1305
- sessionId,
1306
- });
1307
-
1308
- // truthy → no log call
1309
- // falsy → equivalent to log.error("auth", "session", "user vanished...", { sessionId })
1310
- ```
1311
-
1312
- 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`.
1313
-
1314
- ### Why not `console.assert`?
1315
-
1316
- `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).
1317
-
1318
- ## `log.timer(module, action)`
1319
-
1320
- Returns an end-function. Calling it emits an `info` entry with `completed in <ms>ms` and a `durationMs` field in `context`.
1321
-
1322
- ```ts
1323
- const end = log.timer("db", "users.findById");
1324
- const user = await usersRepo.findById(id);
1325
- end({ id, found: !!user });
1326
- // ℹ info [db] [users.findById] completed in 12ms
1327
- // ↳ { durationMs: 12, id: "abc", found: true } (when ConsoleLog has showContext: true)
1328
- ```
1329
-
1330
- Common patterns:
1331
-
1332
- ```ts
1333
- // Around an HTTP handler
1334
- async function handle(req) {
1335
- const end = log.timer("http", `${req.method} ${req.url}`);
1336
- try {
1337
- return await runHandler(req);
1338
- } finally {
1339
- end({ status: res.statusCode });
1340
- }
1341
- }
1342
-
1343
- // Around a job
1344
- const end = log.timer("jobs", "nightly-report");
1345
- await report.run();
1346
- end({ rowsProcessed: report.rowCount });
1347
- ```
1348
-
1349
- `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.
1350
-
1351
- ### Caveats
1352
-
1353
- - The duration is `Date.now()` based — millisecond resolution. For sub-millisecond profiling, reach for `performance.now()` directly.
1354
- - The end-function captures `this` at construction; calling it after the logger is reconfigured still routes through the same `Logger` instance.
1355
- - `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.
1290
+ ---
1291
+ name: use-log-helpers
1292
+ 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`.'
1293
+ ---
1294
+
1295
+ # Helpers — `assert`, `timer`
1296
+
1297
+ 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.
1298
+
1299
+ ## `log.assert(condition, module, action, message, context?)`
1300
+
1301
+ 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.
1302
+
1303
+ ```ts
1304
+ log.assert(user !== null, "auth", "session", "user vanished mid-flight", {
1305
+ sessionId,
1306
+ });
1307
+
1308
+ // truthy → no log call
1309
+ // falsy → equivalent to log.error("auth", "session", "user vanished...", { sessionId })
1310
+ ```
1311
+
1312
+ 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`.
1313
+
1314
+ ### Why not `console.assert`?
1315
+
1316
+ `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).
1317
+
1318
+ ## `log.timer(module, action)`
1319
+
1320
+ Returns an end-function. Calling it emits an `info` entry with `completed in <ms>ms` and a `durationMs` field in `context`.
1321
+
1322
+ ```ts
1323
+ const end = log.timer("db", "users.findById");
1324
+ const user = await usersRepo.findById(id);
1325
+ end({ id, found: !!user });
1326
+ // ℹ info [db] [users.findById] completed in 12ms
1327
+ // ↳ { durationMs: 12, id: "abc", found: true } (when ConsoleLog has showContext: true)
1328
+ ```
1329
+
1330
+ Common patterns:
1331
+
1332
+ ```ts
1333
+ // Around an HTTP handler
1334
+ async function handle(req) {
1335
+ const end = log.timer("http", `${req.method} ${req.url}`);
1336
+ try {
1337
+ return await runHandler(req);
1338
+ } finally {
1339
+ end({ status: res.statusCode });
1340
+ }
1341
+ }
1342
+
1343
+ // Around a job
1344
+ const end = log.timer("jobs", "nightly-report");
1345
+ await report.run();
1346
+ end({ rowsProcessed: report.rowCount });
1347
+ ```
1348
+
1349
+ `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.
1350
+
1351
+ ### Caveats
1352
+
1353
+ - The duration is `Date.now()` based — millisecond resolution. For sub-millisecond profiling, reach for `performance.now()` directly.
1354
+ - The end-function captures `this` at construction; calling it after the logger is reconfigured still routes through the same `Logger` instance.
1355
+ - `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.
1356
1356
 
1357
1357
 
1358
1358
  ## write-custom-log-channel `@warlock.js/logger/write-custom-log-channel/SKILL.md`
1359
1359
 
1360
1360
  ---
1361
1361
  name: write-custom-log-channel
1362
- description: "Custom channels — extending LogChannel in @warlock.js/logger; use when you need to write custom log channel."
1362
+ 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`.'
1363
1363
  ---
1364
1364
 
1365
1365
  # Custom channels — extending `LogChannel`