@warlock.js/logger 5.30.0 → 5.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -5
  2. package/README.md +8 -11
  3. package/cjs/index.cjs +336 -28
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/channels/console-log.d.mts.map +1 -1
  6. package/esm/channels/console-log.mjs.map +1 -1
  7. package/esm/channels/file-log.d.mts +128 -12
  8. package/esm/channels/file-log.d.mts.map +1 -1
  9. package/esm/channels/file-log.mjs +203 -22
  10. package/esm/channels/file-log.mjs.map +1 -1
  11. package/esm/channels/file-name-pattern.d.mts +16 -0
  12. package/esm/channels/file-name-pattern.d.mts.map +1 -0
  13. package/esm/channels/file-name-pattern.mjs +41 -0
  14. package/esm/channels/file-name-pattern.mjs.map +1 -0
  15. package/esm/channels/index.mjs +1 -1
  16. package/esm/channels/json-file-log.d.mts.map +1 -1
  17. package/esm/channels/json-file-log.mjs +24 -5
  18. package/esm/channels/json-file-log.mjs.map +1 -1
  19. package/esm/channels/sentry-log.d.mts.map +1 -1
  20. package/esm/channels/sentry-log.mjs.map +1 -1
  21. package/esm/index.d.mts +5 -3
  22. package/esm/index.mjs +3 -2
  23. package/esm/log-channel.d.mts +23 -1
  24. package/esm/log-channel.d.mts.map +1 -1
  25. package/esm/log-channel.mjs +22 -0
  26. package/esm/log-channel.mjs.map +1 -1
  27. package/esm/logger.d.mts +5 -0
  28. package/esm/logger.d.mts.map +1 -1
  29. package/esm/logger.mjs +11 -2
  30. package/esm/logger.mjs.map +1 -1
  31. package/esm/redact/escape-log-value.d.mts +26 -0
  32. package/esm/redact/escape-log-value.d.mts.map +1 -0
  33. package/esm/redact/escape-log-value.mjs +37 -0
  34. package/esm/redact/escape-log-value.mjs.map +1 -0
  35. package/esm/redact/index.mjs +1 -0
  36. package/esm/redact/redact.d.mts.map +1 -1
  37. package/esm/redact/redact.mjs.map +1 -1
  38. package/esm/types.d.mts +3 -2
  39. package/esm/types.d.mts.map +1 -1
  40. package/llms-full.txt +241 -141
  41. package/llms.txt +2 -2
  42. package/package.json +2 -2
  43. package/skills/capture-unhandled-errors/SKILL.md +9 -10
  44. package/skills/configure-logger/SKILL.md +7 -10
  45. package/skills/filter-log-entries/SKILL.md +2 -2
  46. package/skills/flush-logs-on-shutdown/SKILL.md +32 -13
  47. package/skills/index.md +4 -1
  48. package/skills/logger-basics/SKILL.md +18 -21
  49. package/skills/overview/SKILL.md +17 -6
  50. package/skills/pick-log-channel/SKILL.md +36 -24
  51. package/skills/redact-sensitive-log-fields/SKILL.md +48 -18
  52. package/skills/ship-logs-to-sentry/SKILL.md +18 -18
  53. package/skills/test-logging-code/SKILL.md +4 -2
  54. package/skills/write-custom-log-channel/SKILL.md +50 -17
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: redact-sensitive-log-fields
3
- description: 'Strip secrets from log output — a built-in secret-key denylist on by default (DEFAULT_REDACT_KEYS), plus two-layer additive redaction via log.configure({redact: {paths, keys}}) (logger floor) + per-channel redact (more on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `keys`, `defaultKeys`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging", "turn off default redaction"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — the `filter-log-entries` topic; custom sinks — the `write-custom-log-channel` topic; competing libs `pino.redact`, `fast-redact`.'
3
+ description: 'Strip secrets from log output — a built-in secret-key denylist on by default (DEFAULT_REDACT_KEYS), plus two-layer additive redaction via log.configure({redact: {paths, keys}}) (logger floor) + per-channel redact (more on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `keys`, `defaultKeys`, `censor`, `log.setRedact`, `applyRedact`, `maskEmail`, `maskEmailsInText`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging", "turn off default redaction"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — the `filter-log-entries` topic; custom sinks — the `write-custom-log-channel` topic; competing libs `pino.redact`, `fast-redact`.'
4
4
  ---
5
5
 
6
6
  # Redaction — keeping secrets out of logs
@@ -25,15 +25,15 @@ import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
25
25
 
26
26
  log.configure({
27
27
  redact: {
28
- keys: ["internalRef"], // union with the built-in set
29
- defaultKeys: false, // opt out of the built-in set entirely
28
+ keys: ["internalRef"], // union with the built-in set
29
+ defaultKeys: false, // opt out of the built-in set entirely
30
30
  },
31
31
  });
32
32
  ```
33
33
 
34
34
  `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.
35
35
 
36
- **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.
36
+ **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.
37
37
 
38
38
  Not reachable by any layer: secrets interpolated into a `message` string (`` `token=${t}` ``), `Map`/`Set`/`Buffer` contents, and getter-backed or non-enumerable properties.
39
39
 
@@ -54,18 +54,14 @@ import { log } from "@warlock.js/logger";
54
54
 
55
55
  log.configure({
56
56
  redact: {
57
- paths: [
58
- "context.password",
59
- "context.*.token",
60
- "context.headers.authorization",
61
- ],
62
- censor: "[REDACTED]", // default — string or function
57
+ paths: ["context.password", "context.*.token", "context.headers.authorization"],
58
+ censor: "[REDACTED]", // default — string or function
63
59
  },
64
60
  });
65
61
 
66
62
  // runtime equivalent:
67
63
  log.setRedact({ paths: ["context.password"] });
68
- log.setRedact(undefined); // clear your paths (the default denylist stays on)
64
+ log.setRedact(undefined); // clear your paths (the default denylist stays on)
69
65
  ```
70
66
 
71
67
  Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
@@ -107,13 +103,13 @@ type LoggingData = {
107
103
  };
108
104
  ```
109
105
 
110
- | Pattern | Matches |
111
- | --- | --- |
112
- | `context.password` | exactly `data.context.password` |
113
- | `context.*.token` | `data.context.<any>.token` (one segment in between) |
114
- | `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
115
- | `message.apiKey` | when message is an object, `data.message.apiKey` |
116
- | `context.users.*.token` | array element redaction (`*` matches indices too) |
106
+ | Pattern | Matches |
107
+ | ----------------------- | ------------------------------------------------------------------ |
108
+ | `context.password` | exactly `data.context.password` |
109
+ | `context.*.token` | `data.context.<any>.token` (one segment in between) |
110
+ | `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
111
+ | `message.apiKey` | when message is an object, `data.message.apiKey` |
112
+ | `context.users.*.token` | array element redaction (`*` matches indices too) |
117
113
 
118
114
  Wildcards:
119
115
 
@@ -154,3 +150,37 @@ If `message` is a plain object, paths under `message.*` work as expected. If `me
154
150
  - Each path is matched independently; cost grows linearly with `paths.length`.
155
151
 
156
152
  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.
153
+
154
+ ## Email addresses inside strings
155
+
156
+ ```ts
157
+ import { log, maskEmail, maskEmailsInText } from "@warlock.js/logger";
158
+
159
+ log.info("mail", "send", `Sending to ${maskEmail("member@example.com")}`);
160
+ log.debug("cache", "key", maskEmailsInText("profile:member@example.com"));
161
+ ```
162
+
163
+ Key-based redaction cannot mask a substring inside a message or cache key.
164
+ `maskEmail()` keeps the first code point and domain (`m***@example.com`);
165
+ `maskEmailsInText()` handles text and bounds its scan at 16,384 characters,
166
+ replacing the remaining suffix with a truncation marker.
167
+
168
+ ## Client text inside a message: `escapeLogValue()`
169
+
170
+ A user agent or search term with a line break forges an extra entry in console
171
+ and file logs; an ANSI sequence rewrites the terminal. Escape it first:
172
+
173
+ ```ts
174
+ import { escapeLogValue, log } from "@warlock.js/logger";
175
+
176
+ export function logSearch(term: string, userAgent: string) {
177
+ log.info("search", "query", `"${escapeLogValue(term, { maxLength: 200 })}"`, {
178
+ agent: escapeLogValue(userAgent),
179
+ });
180
+ }
181
+ ```
182
+
183
+ CR/LF/tab become `\r`/`\n`/`\t`, other control characters `\uXXXX`, ANSI
184
+ sequences are removed; `maxLength` cuts and marks `…[truncated]`. `warlock
185
+ check` (from `@warlock.js/core`) reports raw emails, credentials and request
186
+ bodies in log calls as `WL9008` and accepts these helpers.
@@ -50,14 +50,14 @@ With neither `client` nor `options`, the channel reuses whatever global Sentry c
50
50
 
51
51
  This is the quota-control decision. Only `eventLevels` create Sentry **events** (which consume your error quota); every other level becomes a **breadcrumb** that rides along with the next event for free.
52
52
 
53
- | Logger level | Default | Sentry call |
54
- |---|---|---|
55
- | `fatal` | event | `captureException` for an `Error` message, else `captureMessage(…, "fatal")` |
56
- | `error` | event | `captureException` for an `Error` message, else `captureMessage(…, "error")` |
57
- | `warn` | event | `captureMessage(…, "warning")` |
58
- | `success` | breadcrumb | `addBreadcrumb({ level: "info" })` |
59
- | `info` | breadcrumb | `addBreadcrumb({ level: "info" })` |
60
- | `debug` | breadcrumb | `addBreadcrumb({ level: "debug" })` |
53
+ | Logger level | Default | Sentry call |
54
+ | ------------ | ---------- | ---------------------------------------------------------------------------- |
55
+ | `fatal` | event | `captureException` for an `Error` message, else `captureMessage(…, "fatal")` |
56
+ | `error` | event | `captureException` for an `Error` message, else `captureMessage(…, "error")` |
57
+ | `warn` | event | `captureMessage(…, "warning")` |
58
+ | `success` | breadcrumb | `addBreadcrumb({ level: "info" })` |
59
+ | `info` | breadcrumb | `addBreadcrumb({ level: "info" })` |
60
+ | `debug` | breadcrumb | `addBreadcrumb({ level: "debug" })` |
61
61
 
62
62
  - **Errors keep their stack.** A `message` that is an `Error` goes through `captureException`, so Sentry parses the real stack and groups correctly — never pre-stringify the error.
63
63
  - **`module` / `action` become tags** and the entry's `context` becomes a structured Sentry context, both scoped to that single event via `withScope`.
@@ -82,14 +82,14 @@ Sentry sends events asynchronously over the network, so a synchronous flush can'
82
82
  ```ts
83
83
  async function shutdown() {
84
84
  await httpServer.close();
85
- await log.flush(); // SentryLog.flush() → Sentry.flush(flushTimeout)
85
+ await log.flush(); // SentryLog.flush() → Sentry.flush(flushTimeout)
86
86
  process.exit(0);
87
87
  }
88
88
 
89
89
  process.once("SIGTERM", shutdown);
90
90
  ```
91
91
 
92
- `flushTimeout` (default `2000` ms) bounds the wait so an unreachable Sentry can't hang shutdown. `autoFlushOn` uses the **synchronous** `flushSync()`, which does *not* drain Sentry — wire `await log.flush()` yourself. See the `flush-logs-on-shutdown` topic.
92
+ `flushTimeout` (default `2000` ms) bounds the wait so an unreachable Sentry can't hang shutdown. `autoFlushOn` uses the **synchronous** `flushSync()`, which does _not_ drain Sentry — wire `await log.flush()` yourself. See the `flush-logs-on-shutdown` topic.
93
93
 
94
94
  ## If `@sentry/node` isn't installed
95
95
 
@@ -97,17 +97,17 @@ The channel never crashes your app: the dynamic import failure is swallowed, the
97
97
 
98
98
  ## Config reference
99
99
 
100
- | Option | Type | Default | Description |
101
- |---|---|---|---|
102
- | `client` | Sentry namespace / forwarder | — | Reuse an already-initialized Sentry instance. |
103
- | `options` | `SentryInitOptions` | — | Sentry init options (mirrors `@sentry/node`'s `NodeOptions`) — used when the channel owns Sentry. |
104
- | `eventLevels` | `LogLevel[]` | `["fatal", "error", "warn"]` | Levels sent as events; the rest become breadcrumbs. |
105
- | `flushTimeout` | `number` | `2000` | Ms `flush()` waits for the transport to drain. |
106
- | `levels`, `filter`, `dateFormat`, `redact` | — | — | Inherited from `BasicLogConfigurations`. |
100
+ | Option | Type | Default | Description |
101
+ | ------------------------------------------ | ---------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------- |
102
+ | `client` | Sentry namespace / forwarder | — | Reuse an already-initialized Sentry instance. |
103
+ | `options` | `SentryInitOptions` | — | Sentry init options (mirrors `@sentry/node`'s `NodeOptions`) — used when the channel owns Sentry. |
104
+ | `eventLevels` | `LogLevel[]` | `["fatal", "error", "warn"]` | Levels sent as events; the rest become breadcrumbs. |
105
+ | `flushTimeout` | `number` | `2000` | Ms `flush()` waits for the transport to drain. |
106
+ | `levels`, `filter`, `dateFormat`, `redact` | — | — | Inherited from `BasicLogConfigurations`. |
107
107
 
108
108
  ## Don't do
109
109
 
110
- - **Don't double-init Sentry.** Pass *either* `client` *or* `options`, not both. The channel guards against double-init, but a single owner of `Sentry.init` is cleaner.
110
+ - **Don't double-init Sentry.** Pass _either_ `client` _or_ `options`, not both. The channel guards against double-init, but a single owner of `Sentry.init` is cleaner.
111
111
  - **Don't send every level as an event.** `eventLevels` with `info`/`debug`/`success` will flood your Sentry quota — keep them as breadcrumbs.
112
112
  - **Don't rely on `autoFlushOn` for Sentry.** It's synchronous; a network channel needs `await log.flush()`.
113
113
 
@@ -41,7 +41,9 @@ class CapturingChannel extends LogChannel {
41
41
  public name = "capture";
42
42
  public terminal = false;
43
43
  public received: LoggingData[] = [];
44
- public log(data: LoggingData) { this.received.push({ ...data }); }
44
+ public log(data: LoggingData) {
45
+ this.received.push({ ...data });
46
+ }
45
47
  }
46
48
  ```
47
49
 
@@ -100,7 +102,7 @@ No cleanup needed — the local `Logger` is garbage-collected.
100
102
 
101
103
  ## Why not spy on `log.info`?
102
104
 
103
- `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:
105
+ `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:
104
106
 
105
107
  - 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.
106
108
  - 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.
@@ -19,6 +19,7 @@ export class NullChannel extends LogChannel {
19
19
  ```
20
20
 
21
21
  Then:
22
+
22
23
  ```ts
23
24
  log.addChannel(new NullChannel());
24
25
  ```
@@ -27,16 +28,16 @@ That's a working channel. `LogChannel` provides the scaffolding; you only need t
27
28
 
28
29
  ## What `LogChannel` gives you
29
30
 
30
- | Thing | Who provides it |
31
- |---|---|
32
- | `name`, `description`, `terminal` | You (fields on your subclass) |
33
- | `log(data)` | **You must implement** — abstract |
34
- | `flushSync()` | You (optional sync drain — only if you buffer) |
35
- | `flush()` | You (optional async drain — only if you buffer over async I/O) |
36
- | `init()` | You (optional async hook — see below) |
37
- | `shouldBeLogged(data)` | `LogChannel` — combines `levels` + `filter` |
38
- | `config<K>(key)` | `LogChannel` — merges user config with `defaultConfigurations` |
39
- | `getDateAndTimeFormat()` | `LogChannel` — returns resolved `dateFormat` |
31
+ | Thing | Who provides it |
32
+ | --------------------------------- | -------------------------------------------------------------- |
33
+ | `name`, `description`, `terminal` | You (fields on your subclass) |
34
+ | `log(data)` | **You must implement** — abstract |
35
+ | `flushSync()` | You (optional sync drain — only if you buffer) |
36
+ | `flush()` | You (optional async drain — only if you buffer over async I/O) |
37
+ | `init()` | You (optional async hook — see below) |
38
+ | `shouldBeLogged(data)` | `LogChannel` — combines `levels` + `filter` |
39
+ | `config<K>(key)` | `LogChannel` — merges user config with `defaultConfigurations` |
40
+ | `getDateAndTimeFormat()` | `LogChannel` — returns resolved `dateFormat` |
40
41
 
41
42
  ## Complete example — SlackLog
42
43
 
@@ -54,7 +55,7 @@ export class SlackLog extends LogChannel<SlackConfig> {
54
55
  public description = "Posts errors + warnings to a Slack webhook";
55
56
 
56
57
  public async log(data: LoggingData) {
57
- if (!this.shouldBeLogged(data)) return; // ← inherit levels + filter
58
+ if (!this.shouldBeLogged(data)) return; // ← inherit levels + filter
58
59
 
59
60
  await fetch(this.config("webhookUrl"), {
60
61
  method: "POST",
@@ -85,9 +86,7 @@ log.setChannels([
85
86
  Override `protected async init()` for one-time setup — open a socket, connect to a DB, prepare a write stream. Runs automatically after construction (inside a `setTimeout(0)`); `isInitialized` flips to `true` once resolved.
86
87
 
87
88
  ```ts
88
- export class DatabaseLog extends LogChannel<
89
- BasicLogConfigurations & { connectionString: string }
90
- > {
89
+ export class DatabaseLog extends LogChannel<BasicLogConfigurations & { connectionString: string }> {
91
90
  public name = "database";
92
91
  private client!: SomeDbClient;
93
92
 
@@ -102,6 +101,38 @@ export class DatabaseLog extends LogChannel<
102
101
  }
103
102
  ```
104
103
 
104
+ ## Reporting background failures — `reportError()`
105
+
106
+ A failure inside `log()` reaches `log.onChannelError(...)` on its own. For work outside a `log()` call (a timer, a reconnect, a cleanup in `init()`), call `this.reportError(error, entry)`: the logger the channel was added to forwards it to its handler, and a channel not yet added writes one stderr line. It never throws.
107
+
108
+ ```ts
109
+ export class QueueLog extends LogChannel {
110
+ public name = "queue";
111
+
112
+ protected init() {
113
+ setInterval(() => {
114
+ this.drain().catch((error) =>
115
+ this.reportError(error, {
116
+ type: "error",
117
+ module: "logger",
118
+ action: "drain",
119
+ message: "Queue drain failed",
120
+ }),
121
+ );
122
+ }, 1_000);
123
+ }
124
+
125
+ public log(data: LoggingData) {
126
+ if (!this.shouldBeLogged(data)) return;
127
+ // enqueue data …
128
+ }
129
+
130
+ private async drain() {
131
+ // send queued entries …
132
+ }
133
+ }
134
+ ```
135
+
105
136
  ## Implementing `flushSync()`
106
137
 
107
138
  Only if your channel buffers. Signature: `flushSync?(): void`. Synchronous — no `await`, no promises.
@@ -123,7 +154,9 @@ export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: str
123
154
  // so a follow-up async drain can recover it next boot.
124
155
  }
125
156
 
126
- private async drain() { /* async post to this.config("url") */ }
157
+ private async drain() {
158
+ /* async post to this.config("url") */
159
+ }
127
160
  }
128
161
  ```
129
162
 
@@ -143,7 +176,7 @@ export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: str
143
176
  }
144
177
 
145
178
  public async flush() {
146
- await this.drain(); // awaited by log.flush() on shutdown
179
+ await this.drain(); // awaited by log.flush() on shutdown
147
180
  }
148
181
 
149
182
  private async drain() {
@@ -175,7 +208,7 @@ class MinimalSlack implements LogContract {
175
208
 
176
209
  public async log(data: LoggingData) {
177
210
  if (data.type !== "error") return;
178
- await fetch(process.env.SLACK_WEBHOOK!, { /* ... */ });
211
+ await fetch(process.env.SLACK_WEBHOOK!, {/* ... */});
179
212
  }
180
213
  }
181
214
  ```