@warlock.js/logger 5.24.0 → 5.26.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.
@@ -1,156 +1,156 @@
1
- ---
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 — `@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`.'
4
- ---
5
-
6
- # Redaction — keeping secrets out of logs
7
-
8
- 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.
9
-
10
- ## Layer 0 — the default denylist (since 4.15.0, no configuration needed)
11
-
12
- 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:
13
-
14
- ```ts
15
- log.error("auth", "login", "failed", { headers: req.headers, body: req.body });
16
- // context.headers.authorization → "[REDACTED]"
17
- // context.body.password → "[REDACTED]"
18
- // context.body.email → untouched
19
- ```
20
-
21
- 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`.
22
-
23
- ```ts
24
- import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
25
-
26
- log.configure({
27
- redact: {
28
- keys: ["internalRef"], // union with the built-in set
29
- defaultKeys: false, // opt out of the built-in set entirely
30
- },
31
- });
32
- ```
33
-
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
-
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
-
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
-
40
- ## Layers 1 & 2 — path globs, opt-in
41
-
42
- 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.
43
-
44
- The model in one line:
45
-
46
- > Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
47
-
48
- 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.
49
-
50
- ## Logger-wide floor
51
-
52
- ```ts
53
- import { log } from "@warlock.js/logger";
54
-
55
- log.configure({
56
- redact: {
57
- paths: [
58
- "context.password",
59
- "context.*.token",
60
- "context.headers.authorization",
61
- ],
62
- censor: "[REDACTED]", // default — string or function
63
- },
64
- });
65
-
66
- // runtime equivalent:
67
- log.setRedact({ paths: ["context.password"] });
68
- log.setRedact(undefined); // clear your paths (the default denylist stays on)
69
- ```
70
-
71
- Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
72
-
73
- ## Per-channel additive
74
-
75
- ```ts
76
- new SlackChannel({
77
- webhook: "...",
78
- redact: {
79
- paths: ["context.user.email", "context.metadata.*"],
80
- // censor inherited from logger-wide when omitted
81
- },
82
- });
83
- ```
84
-
85
- 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.
86
-
87
- ### When to set redact per-channel
88
-
89
- - Loud destinations with broader audiences (Slack, Discord, error trackers, anything off your machine) — redact more aggressively.
90
- - Local-only destinations (FileLog you alone read, the dev terminal) — keep the floor minimal so you can debug.
91
-
92
- ### When NOT to set it
93
-
94
- 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.
95
-
96
- ## Path syntax
97
-
98
- Paths are dotted glob patterns evaluated against the full `LoggingData`:
99
-
100
- ```
101
- type LoggingData = {
102
- type: "info" | ...,
103
- module: string,
104
- action: string,
105
- message: any, // ← prefix paths with "message." to redact here
106
- context?: object, // ← prefix paths with "context." to redact here
107
- };
108
- ```
109
-
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) |
117
-
118
- Wildcards:
119
-
120
- - `*` — exactly one segment (any object key, any array index).
121
- - `**` — zero or more segments, greedily; matches at any depth.
122
-
123
- ## Censor variants
124
-
125
- ```ts
126
- // String — replace with a literal.
127
- { censor: "[REDACTED]" }
128
- { censor: "***" }
129
-
130
- // Function — receives original value + dotted path, returns the replacement.
131
- {
132
- censor: (value, path) => {
133
- if (typeof value !== "string") return "[REDACTED]";
134
- return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***";
135
- },
136
- }
137
- ```
138
-
139
- 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).
140
-
141
- ## Immutability
142
-
143
- `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.
144
-
145
- ## What about the `message` field?
146
-
147
- 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.
148
-
149
- ## Performance notes
150
-
151
- - **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.
152
- - **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
153
- - **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.
154
- - Each path is matched independently; cost grows linearly with `paths.length`.
155
-
156
- 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.
1
+ ---
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 — `@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`.'
4
+ ---
5
+
6
+ # Redaction — keeping secrets out of logs
7
+
8
+ 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.
9
+
10
+ ## Layer 0 — the default denylist (since 4.15.0, no configuration needed)
11
+
12
+ 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:
13
+
14
+ ```ts
15
+ log.error("auth", "login", "failed", { headers: req.headers, body: req.body });
16
+ // context.headers.authorization → "[REDACTED]"
17
+ // context.body.password → "[REDACTED]"
18
+ // context.body.email → untouched
19
+ ```
20
+
21
+ 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`.
22
+
23
+ ```ts
24
+ import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
25
+
26
+ log.configure({
27
+ redact: {
28
+ keys: ["internalRef"], // union with the built-in set
29
+ defaultKeys: false, // opt out of the built-in set entirely
30
+ },
31
+ });
32
+ ```
33
+
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
+
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
+
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
+
40
+ ## Layers 1 & 2 — path globs, opt-in
41
+
42
+ 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.
43
+
44
+ The model in one line:
45
+
46
+ > Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
47
+
48
+ 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.
49
+
50
+ ## Logger-wide floor
51
+
52
+ ```ts
53
+ import { log } from "@warlock.js/logger";
54
+
55
+ log.configure({
56
+ redact: {
57
+ paths: [
58
+ "context.password",
59
+ "context.*.token",
60
+ "context.headers.authorization",
61
+ ],
62
+ censor: "[REDACTED]", // default — string or function
63
+ },
64
+ });
65
+
66
+ // runtime equivalent:
67
+ log.setRedact({ paths: ["context.password"] });
68
+ log.setRedact(undefined); // clear your paths (the default denylist stays on)
69
+ ```
70
+
71
+ Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
72
+
73
+ ## Per-channel additive
74
+
75
+ ```ts
76
+ new SlackChannel({
77
+ webhook: "...",
78
+ redact: {
79
+ paths: ["context.user.email", "context.metadata.*"],
80
+ // censor inherited from logger-wide when omitted
81
+ },
82
+ });
83
+ ```
84
+
85
+ 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.
86
+
87
+ ### When to set redact per-channel
88
+
89
+ - Loud destinations with broader audiences (Slack, Discord, error trackers, anything off your machine) — redact more aggressively.
90
+ - Local-only destinations (FileLog you alone read, the dev terminal) — keep the floor minimal so you can debug.
91
+
92
+ ### When NOT to set it
93
+
94
+ 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.
95
+
96
+ ## Path syntax
97
+
98
+ Paths are dotted glob patterns evaluated against the full `LoggingData`:
99
+
100
+ ```
101
+ type LoggingData = {
102
+ type: "info" | ...,
103
+ module: string,
104
+ action: string,
105
+ message: any, // ← prefix paths with "message." to redact here
106
+ context?: object, // ← prefix paths with "context." to redact here
107
+ };
108
+ ```
109
+
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) |
117
+
118
+ Wildcards:
119
+
120
+ - `*` — exactly one segment (any object key, any array index).
121
+ - `**` — zero or more segments, greedily; matches at any depth.
122
+
123
+ ## Censor variants
124
+
125
+ ```ts
126
+ // String — replace with a literal.
127
+ { censor: "[REDACTED]" }
128
+ { censor: "***" }
129
+
130
+ // Function — receives original value + dotted path, returns the replacement.
131
+ {
132
+ censor: (value, path) => {
133
+ if (typeof value !== "string") return "[REDACTED]";
134
+ return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***";
135
+ },
136
+ }
137
+ ```
138
+
139
+ 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).
140
+
141
+ ## Immutability
142
+
143
+ `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.
144
+
145
+ ## What about the `message` field?
146
+
147
+ 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.
148
+
149
+ ## Performance notes
150
+
151
+ - **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.
152
+ - **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
153
+ - **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.
154
+ - Each path is matched independently; cost grows linearly with `paths.length`.
155
+
156
+ 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.
@@ -1,118 +1,118 @@
1
- ---
2
- name: ship-logs-to-sentry
3
- 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.'
4
- ---
5
-
6
- # Ship logs to Sentry — the `SentryLog` channel
7
-
8
- `SentryLog` forwards log entries to Sentry. It's the one built-in channel that needs an external SDK, so `@sentry/node` is an **optional peer** — install it only if you use this channel:
9
-
10
- ```bash
11
- npm install @sentry/node
12
- ```
13
-
14
- ## Two ways to wire it
15
-
16
- ### Reuse an existing Sentry client (existing apps)
17
-
18
- If your app already calls `Sentry.init(...)`, pass the namespace as `client`. The channel forwards through it and never re-imports or re-initializes the SDK:
19
-
20
- ```ts
21
- import * as Sentry from "@sentry/node";
22
- import { log, SentryLog } from "@warlock.js/logger";
23
-
24
- Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production" });
25
-
26
- log.addChannel(new SentryLog({ client: Sentry }));
27
- ```
28
-
29
- ### Let the channel initialize Sentry (new apps)
30
-
31
- Pass `options`; the channel lazily imports `@sentry/node` and calls `Sentry.init` once — guarded so it never clobbers an existing client:
32
-
33
- ```ts
34
- import { log, SentryLog } from "@warlock.js/logger";
35
-
36
- log.addChannel(
37
- new SentryLog({
38
- options: {
39
- dsn: process.env.SENTRY_DSN,
40
- environment: process.env.NODE_ENV,
41
- release: process.env.GIT_SHA,
42
- },
43
- }),
44
- );
45
- ```
46
-
47
- With neither `client` nor `options`, the channel reuses whatever global Sentry client the host already initialized.
48
-
49
- ## How levels map to Sentry
50
-
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
-
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
-
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
- - **`module` / `action` become tags** and the entry's `context` becomes a structured Sentry context, both scoped to that single event via `withScope`.
64
- - **`success` has no Sentry severity** — it's reported as `info`.
65
-
66
- ### Tuning what becomes an event
67
-
68
- ```ts
69
- // Only errors create events; warnings drop to breadcrumbs.
70
- new SentryLog({ client: Sentry, eventLevels: ["error"] });
71
-
72
- // Errors + warnings + an info stream as events (noisier, more quota).
73
- new SentryLog({ client: Sentry, eventLevels: ["error", "warn", "info"] });
74
- ```
75
-
76
- `levels` and `filter` from `BasicLogConfigurations` apply first — a channel-level `levels: ["error", "warn"]` drops everything else before it reaches Sentry at all.
77
-
78
- ## Draining on shutdown
79
-
80
- Sentry sends events asynchronously over the network, so a synchronous flush can't wait on them. `SentryLog.flush()` calls `Sentry.flush(timeout)`; drain it on your graceful-shutdown path:
81
-
82
- ```ts
83
- async function shutdown() {
84
- await httpServer.close();
85
- await log.flush(); // SentryLog.flush() → Sentry.flush(flushTimeout)
86
- process.exit(0);
87
- }
88
-
89
- process.once("SIGTERM", shutdown);
90
- ```
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 [`flush-logs-on-shutdown`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md).
93
-
94
- ## If `@sentry/node` isn't installed
95
-
96
- The channel never crashes your app: the dynamic import failure is swallowed, the install instructions are written to stderr **once**, and entries are dropped silently thereafter. So registering `SentryLog` in shared config is safe even in an environment where Sentry isn't installed.
97
-
98
- ## Config reference
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`. |
107
-
108
- ## Don't do
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.
111
- - **Don't send every level as an event.** `eventLevels` with `info`/`debug`/`success` will flood your Sentry quota — keep them as breadcrumbs.
112
- - **Don't rely on `autoFlushOn` for Sentry.** It's synchronous; a network channel needs `await log.flush()`.
113
-
114
- ## See also
115
-
116
- - [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) — `await log.flush()` on shutdown
117
- - [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md) — the console / file channels
118
- - [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md) — build your own sink
1
+ ---
2
+ name: ship-logs-to-sentry
3
+ 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.'
4
+ ---
5
+
6
+ # Ship logs to Sentry — the `SentryLog` channel
7
+
8
+ `SentryLog` forwards log entries to Sentry. It's the one built-in channel that needs an external SDK, so `@sentry/node` is an **optional peer** — install it only if you use this channel:
9
+
10
+ ```bash
11
+ npm install @sentry/node
12
+ ```
13
+
14
+ ## Two ways to wire it
15
+
16
+ ### Reuse an existing Sentry client (existing apps)
17
+
18
+ If your app already calls `Sentry.init(...)`, pass the namespace as `client`. The channel forwards through it and never re-imports or re-initializes the SDK:
19
+
20
+ ```ts
21
+ import * as Sentry from "@sentry/node";
22
+ import { log, SentryLog } from "@warlock.js/logger";
23
+
24
+ Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production" });
25
+
26
+ log.addChannel(new SentryLog({ client: Sentry }));
27
+ ```
28
+
29
+ ### Let the channel initialize Sentry (new apps)
30
+
31
+ Pass `options`; the channel lazily imports `@sentry/node` and calls `Sentry.init` once — guarded so it never clobbers an existing client:
32
+
33
+ ```ts
34
+ import { log, SentryLog } from "@warlock.js/logger";
35
+
36
+ log.addChannel(
37
+ new SentryLog({
38
+ options: {
39
+ dsn: process.env.SENTRY_DSN,
40
+ environment: process.env.NODE_ENV,
41
+ release: process.env.GIT_SHA,
42
+ },
43
+ }),
44
+ );
45
+ ```
46
+
47
+ With neither `client` nor `options`, the channel reuses whatever global Sentry client the host already initialized.
48
+
49
+ ## How levels map to Sentry
50
+
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
+
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
+
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
+ - **`module` / `action` become tags** and the entry's `context` becomes a structured Sentry context, both scoped to that single event via `withScope`.
64
+ - **`success` has no Sentry severity** — it's reported as `info`.
65
+
66
+ ### Tuning what becomes an event
67
+
68
+ ```ts
69
+ // Only errors create events; warnings drop to breadcrumbs.
70
+ new SentryLog({ client: Sentry, eventLevels: ["error"] });
71
+
72
+ // Errors + warnings + an info stream as events (noisier, more quota).
73
+ new SentryLog({ client: Sentry, eventLevels: ["error", "warn", "info"] });
74
+ ```
75
+
76
+ `levels` and `filter` from `BasicLogConfigurations` apply first — a channel-level `levels: ["error", "warn"]` drops everything else before it reaches Sentry at all.
77
+
78
+ ## Draining on shutdown
79
+
80
+ Sentry sends events asynchronously over the network, so a synchronous flush can't wait on them. `SentryLog.flush()` calls `Sentry.flush(timeout)`; drain it on your graceful-shutdown path:
81
+
82
+ ```ts
83
+ async function shutdown() {
84
+ await httpServer.close();
85
+ await log.flush(); // SentryLog.flush() → Sentry.flush(flushTimeout)
86
+ process.exit(0);
87
+ }
88
+
89
+ process.once("SIGTERM", shutdown);
90
+ ```
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 [`flush-logs-on-shutdown`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md).
93
+
94
+ ## If `@sentry/node` isn't installed
95
+
96
+ The channel never crashes your app: the dynamic import failure is swallowed, the install instructions are written to stderr **once**, and entries are dropped silently thereafter. So registering `SentryLog` in shared config is safe even in an environment where Sentry isn't installed.
97
+
98
+ ## Config reference
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`. |
107
+
108
+ ## Don't do
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.
111
+ - **Don't send every level as an event.** `eventLevels` with `info`/`debug`/`success` will flood your Sentry quota — keep them as breadcrumbs.
112
+ - **Don't rely on `autoFlushOn` for Sentry.** It's synchronous; a network channel needs `await log.flush()`.
113
+
114
+ ## See also
115
+
116
+ - [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) — `await log.flush()` on shutdown
117
+ - [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md) — the console / file channels
118
+ - [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md) — build your own sink