@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.
- package/CHANGELOG.md +26 -5
- package/README.md +8 -11
- package/cjs/index.cjs +336 -28
- package/cjs/index.cjs.map +1 -1
- package/esm/channels/console-log.d.mts.map +1 -1
- package/esm/channels/console-log.mjs.map +1 -1
- package/esm/channels/file-log.d.mts +128 -12
- package/esm/channels/file-log.d.mts.map +1 -1
- package/esm/channels/file-log.mjs +203 -22
- package/esm/channels/file-log.mjs.map +1 -1
- package/esm/channels/file-name-pattern.d.mts +16 -0
- package/esm/channels/file-name-pattern.d.mts.map +1 -0
- package/esm/channels/file-name-pattern.mjs +41 -0
- package/esm/channels/file-name-pattern.mjs.map +1 -0
- package/esm/channels/index.mjs +1 -1
- package/esm/channels/json-file-log.d.mts.map +1 -1
- package/esm/channels/json-file-log.mjs +24 -5
- package/esm/channels/json-file-log.mjs.map +1 -1
- package/esm/channels/sentry-log.d.mts.map +1 -1
- package/esm/channels/sentry-log.mjs.map +1 -1
- package/esm/index.d.mts +5 -3
- package/esm/index.mjs +3 -2
- package/esm/log-channel.d.mts +23 -1
- package/esm/log-channel.d.mts.map +1 -1
- package/esm/log-channel.mjs +22 -0
- package/esm/log-channel.mjs.map +1 -1
- package/esm/logger.d.mts +5 -0
- package/esm/logger.d.mts.map +1 -1
- package/esm/logger.mjs +11 -2
- package/esm/logger.mjs.map +1 -1
- package/esm/redact/escape-log-value.d.mts +26 -0
- package/esm/redact/escape-log-value.d.mts.map +1 -0
- package/esm/redact/escape-log-value.mjs +37 -0
- package/esm/redact/escape-log-value.mjs.map +1 -0
- package/esm/redact/index.mjs +1 -0
- package/esm/redact/redact.d.mts.map +1 -1
- package/esm/redact/redact.mjs.map +1 -1
- package/esm/types.d.mts +3 -2
- package/esm/types.d.mts.map +1 -1
- package/llms-full.txt +241 -141
- package/llms.txt +2 -2
- package/package.json +2 -2
- package/skills/capture-unhandled-errors/SKILL.md +9 -10
- package/skills/configure-logger/SKILL.md +7 -10
- package/skills/filter-log-entries/SKILL.md +2 -2
- package/skills/flush-logs-on-shutdown/SKILL.md +32 -13
- package/skills/index.md +4 -1
- package/skills/logger-basics/SKILL.md +18 -21
- package/skills/overview/SKILL.md +17 -6
- package/skills/pick-log-channel/SKILL.md +36 -24
- package/skills/redact-sensitive-log-fields/SKILL.md +48 -18
- package/skills/ship-logs-to-sentry/SKILL.md +18 -18
- package/skills/test-logging-code/SKILL.md +4 -2
- 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"],
|
|
29
|
-
defaultKeys: false,
|
|
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
|
|
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
|
-
|
|
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);
|
|
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
|
|
111
|
-
|
|
|
112
|
-
| `context.password`
|
|
113
|
-
| `context.*.token`
|
|
114
|
-
| `**.password`
|
|
115
|
-
| `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
|
|
54
|
-
|
|
55
|
-
| `fatal`
|
|
56
|
-
| `error`
|
|
57
|
-
| `warn`
|
|
58
|
-
| `success`
|
|
59
|
-
| `info`
|
|
60
|
-
| `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();
|
|
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
|
|
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
|
|
101
|
-
|
|
102
|
-
| `client`
|
|
103
|
-
| `options`
|
|
104
|
-
| `eventLevels`
|
|
105
|
-
| `flushTimeout`
|
|
106
|
-
| `levels`, `filter`, `dateFormat`, `redact` | —
|
|
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
|
|
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) {
|
|
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")`
|
|
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
|
|
31
|
-
|
|
32
|
-
| `name`, `description`, `terminal` | You (fields on your subclass)
|
|
33
|
-
| `log(data)`
|
|
34
|
-
| `flushSync()`
|
|
35
|
-
| `flush()`
|
|
36
|
-
| `init()`
|
|
37
|
-
| `shouldBeLogged(data)`
|
|
38
|
-
| `config<K>(key)`
|
|
39
|
-
| `getDateAndTimeFormat()`
|
|
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;
|
|
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() {
|
|
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();
|
|
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
|
```
|