@warlock.js/logger 5.30.0 → 5.32.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 +32 -5
- package/README.md +8 -11
- package/cjs/index.cjs +339 -31
- package/cjs/index.cjs.map +1 -1
- package/esm/channels/console-log.d.mts +2 -4
- 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 +130 -15
- package/esm/channels/file-log.d.mts.map +1 -1
- package/esm/channels/file-log.mjs +204 -23
- package/esm/channels/file-log.mjs.map +1 -1
- package/esm/channels/file-name-pattern.d.mts +15 -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 +2 -2
- package/esm/channels/json-file-log.d.mts +1 -3
- 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 +7 -8
- 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 -3
- 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 +8 -5
- package/esm/logger.d.mts.map +1 -1
- package/esm/logger.mjs +12 -3
- package/esm/logger.mjs.map +1 -1
- package/esm/redact/default-keys.d.mts +3 -4
- package/esm/redact/default-keys.d.mts.map +1 -1
- package/esm/redact/escape-log-value.d.mts +25 -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 +2 -1
- package/esm/redact/mask-email.d.mts +3 -4
- package/esm/redact/mask-email.d.mts.map +1 -1
- package/esm/redact/redact.d.mts +3 -5
- package/esm/redact/redact.d.mts.map +1 -1
- package/esm/redact/redact.mjs.map +1 -1
- package/esm/types.d.mts +14 -14
- package/esm/types.d.mts.map +1 -1
- package/esm/utils/capture-unhandled-errors.d.mts +2 -3
- package/esm/utils/capture-unhandled-errors.d.mts.map +1 -1
- package/esm/utils/clear-message.d.mts +1 -2
- package/esm/utils/clear-message.d.mts.map +1 -1
- package/esm/utils/index.mjs +1 -1
- package/esm/utils/safe-json-stringify.d.mts +1 -2
- package/esm/utils/safe-json-stringify.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
package/llms-full.txt
CHANGED
|
@@ -24,6 +24,7 @@ captureAnyUnhandledRejection();
|
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
Registers:
|
|
27
|
+
|
|
27
28
|
- `process.on("unhandledRejection", reason => log.error("app", "unhandledRejection", reason))` — logged; the process is kept alive.
|
|
28
29
|
- `process.on("uncaughtException", error => log.fatal("app", "uncaughtException", error))` — logged, then `process.exit(1)` (by default).
|
|
29
30
|
|
|
@@ -31,7 +32,7 @@ The split is intentional: an `uncaughtException` leaves the process in an undefi
|
|
|
31
32
|
|
|
32
33
|
## Why it exits (and why that matters)
|
|
33
34
|
|
|
34
|
-
Registering
|
|
35
|
+
Registering _any_ `uncaughtException` listener **suppresses** Node's default "print the stack + exit non-zero." A listener that only logs therefore turns an unrecoverable crash into a silent `exit 0` — which is exactly how a config file that throws at boot can look like "the server started, then just stopped," with no error printed. So the handler restores the contract:
|
|
35
36
|
|
|
36
37
|
- **Exits non-zero** after logging (`process.exit(1)`), following a best-effort, time-bounded `log.flush()` so buffered `FileLog` / `SentryLog` entries drain first. Opt out with `captureAnyUnhandledRejection({ exitOnUncaughtException: false })` where the process is expected to recover on its own (a dev server reloading via HMR).
|
|
37
38
|
- **Falls back to `console.error`** when no terminal channel is configured yet — the early-boot window, before `log.configure(...)`, where `log.fatal` has nowhere visible to go. When a `ConsoleLog` is present it already prints the entry, so the fallback is skipped (no double output).
|
|
@@ -43,16 +44,11 @@ Registering *any* `uncaughtException` listener **suppresses** Node's default "pr
|
|
|
43
44
|
**Once**, at startup, **after** channels are registered. Typical place: immediately after your `log.configure({...})` call.
|
|
44
45
|
|
|
45
46
|
```ts title="src/index.ts"
|
|
46
|
-
import {
|
|
47
|
-
log,
|
|
48
|
-
ConsoleLog,
|
|
49
|
-
FileLog,
|
|
50
|
-
captureAnyUnhandledRejection,
|
|
51
|
-
} from "@warlock.js/logger";
|
|
47
|
+
import { log, ConsoleLog, FileLog, captureAnyUnhandledRejection } from "@warlock.js/logger";
|
|
52
48
|
|
|
53
49
|
log.configure({
|
|
54
50
|
channels: [new ConsoleLog(), new FileLog({ levels: ["error"] })],
|
|
55
|
-
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
51
|
+
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"], // ← important; see below
|
|
56
52
|
});
|
|
57
53
|
|
|
58
54
|
captureAnyUnhandledRejection();
|
|
@@ -62,7 +58,7 @@ captureAnyUnhandledRejection();
|
|
|
62
58
|
|
|
63
59
|
The `uncaughtException` handler runs a best-effort, time-bounded `log.flush()` **before** its own `process.exit(1)`, so buffered `FileLog` / `SentryLog` entries drain even though `process.exit()` skips `beforeExit`. You don't need `autoFlushOn: ["beforeExit"]` for the fatal entry to survive — the handler already drains.
|
|
64
60
|
|
|
65
|
-
`"beforeExit"` in `autoFlushOn` is still worth setting for the
|
|
61
|
+
`"beforeExit"` in `autoFlushOn` is still worth setting for the _other_ exit routes (a natural drain when the event loop empties on its own). For signal-driven shutdown (`SIGINT` / `SIGTERM`), include those signals in `autoFlushOn`. See the `flush-logs-on-shutdown` topic.
|
|
66
62
|
|
|
67
63
|
## Idempotency — don't call it twice
|
|
68
64
|
|
|
@@ -87,7 +83,9 @@ import type { LoggingData } from "@warlock.js/logger";
|
|
|
87
83
|
class Capture extends LogChannel {
|
|
88
84
|
public name = "capture";
|
|
89
85
|
public received: LoggingData[] = [];
|
|
90
|
-
public log(data: LoggingData) {
|
|
86
|
+
public log(data: LoggingData) {
|
|
87
|
+
this.received.push({ ...data });
|
|
88
|
+
}
|
|
91
89
|
}
|
|
92
90
|
|
|
93
91
|
it("routes unhandled rejections to the logger", async () => {
|
|
@@ -112,6 +110,7 @@ Testing the **`uncaughtException`** path additionally trips `process.exit(1)`, s
|
|
|
112
110
|
## Module + action the capture uses
|
|
113
111
|
|
|
114
112
|
Both listeners log with:
|
|
113
|
+
|
|
115
114
|
- `module: "app"`
|
|
116
115
|
- `action: "unhandledRejection"` (at `error`) or `action: "uncaughtException"` (at `fatal`)
|
|
117
116
|
- `message`: the rejection reason / exception (keep it as the raw `Error` object — file channels capture the stack).
|
|
@@ -132,10 +131,10 @@ The logger is a singleton. Do all setup in one place, as early in the app entry
|
|
|
132
131
|
|
|
133
132
|
## The three channel-registration methods
|
|
134
133
|
|
|
135
|
-
| Method
|
|
136
|
-
|
|
137
|
-
| `log.addChannel(channel)`
|
|
138
|
-
| `log.setChannels([...])`
|
|
134
|
+
| Method | Semantics |
|
|
135
|
+
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
136
|
+
| `log.addChannel(channel)` | **Appends.** Safe to call multiple times. |
|
|
137
|
+
| `log.setChannels([...])` | **Replaces** the full list. |
|
|
139
138
|
| `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
139
|
|
|
141
140
|
All three return `this` — chainable.
|
|
@@ -154,7 +153,7 @@ if (process.env.NODE_ENV === "production") {
|
|
|
154
153
|
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
155
154
|
});
|
|
156
155
|
} else if (process.env.NODE_ENV === "test") {
|
|
157
|
-
log.setChannels([]);
|
|
156
|
+
log.setChannels([]); // silence logger during tests
|
|
158
157
|
} else {
|
|
159
158
|
log.setChannels([new ConsoleLog()]);
|
|
160
159
|
}
|
|
@@ -163,7 +162,7 @@ if (process.env.NODE_ENV === "production") {
|
|
|
163
162
|
Import it once at the top of `src/index.ts`:
|
|
164
163
|
|
|
165
164
|
```ts title="src/index.ts"
|
|
166
|
-
import "./logger";
|
|
165
|
+
import "./logger"; // side-effect: configures singleton
|
|
167
166
|
import { log } from "@warlock.js/logger";
|
|
168
167
|
|
|
169
168
|
log.info("app", "start", "Server listening on :3000");
|
|
@@ -210,10 +209,7 @@ Every `new Logger()` gets a unique `id` (string, prefixed `"logger-"`).
|
|
|
210
209
|
|
|
211
210
|
```ts
|
|
212
211
|
log.configure({
|
|
213
|
-
channels: [
|
|
214
|
-
new ConsoleLog({ showContext: true }),
|
|
215
|
-
new FileLog({ chunk: "daily" }),
|
|
216
|
-
],
|
|
212
|
+
channels: [new ConsoleLog({ showContext: true }), new FileLog({ chunk: "daily" })],
|
|
217
213
|
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
218
214
|
redact: { paths: ["context.password", "context.headers.authorization"] },
|
|
219
215
|
minLevel: process.env.LOG_LEVEL === "debug" ? "debug" : "info",
|
|
@@ -270,7 +266,7 @@ For the common "drop everything below X" case, skip the per-channel `levels` arr
|
|
|
270
266
|
log.setMinLevel("info");
|
|
271
267
|
// debug entries are dropped before fan-out — no channel ever sees them.
|
|
272
268
|
|
|
273
|
-
log.configure({ minLevel: "warn" });
|
|
269
|
+
log.configure({ minLevel: "warn" }); // shorthand inside configure()
|
|
274
270
|
```
|
|
275
271
|
|
|
276
272
|
Severity ordering: `debug < info ≈ success < warn < error < fatal`. `success` is treated as informational severity — `setMinLevel("warn")` drops it. `fatal` is strictly above `error`, so `setMinLevel("fatal")` admits only fatal entries (handy for "page me only on fatal" routing).
|
|
@@ -278,7 +274,7 @@ Severity ordering: `debug < info ≈ success < warn < error < fatal`. `success`
|
|
|
278
274
|
Pass `undefined` to clear:
|
|
279
275
|
|
|
280
276
|
```ts
|
|
281
|
-
log.setMinLevel(undefined);
|
|
277
|
+
log.setMinLevel(undefined); // accept everything again
|
|
282
278
|
```
|
|
283
279
|
|
|
284
280
|
This runs **before** the channel loop — cheaper than per-channel `levels` filters when you want a uniform floor. Per-channel `levels` and `filter` still run on top for channels that need a tighter or differently-shaped rule.
|
|
@@ -356,7 +352,7 @@ The `minLevel` check is the fastest of the three (single comparison before fan-o
|
|
|
356
352
|
|
|
357
353
|
---
|
|
358
354
|
name: 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 — the `capture-unhandled-errors` topic; custom sinks — the `write-custom-log-channel` topic; competing `pino.final`, `winston.end`; native `process.on(''exit'')`.'
|
|
355
|
+
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`, `onChannelError`, `log.flush({ timeout })`, `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 — the `capture-unhandled-errors` topic; custom sinks — the `write-custom-log-channel` topic; competing `pino.final`, `winston.end`; native `process.on(''exit'')`.'
|
|
360
356
|
---
|
|
361
357
|
|
|
362
358
|
# Lifecycle — flushing buffered channels before exit
|
|
@@ -376,14 +372,15 @@ log.configure({
|
|
|
376
372
|
|
|
377
373
|
### What each event does
|
|
378
374
|
|
|
379
|
-
| Event
|
|
380
|
-
|
|
375
|
+
| Event | Behavior |
|
|
376
|
+
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
381
377
|
| `SIGINT` / `SIGTERM` / `SIGHUP` / `SIGBREAK` / `SIGUSR2` | Flush → remove this handler → re-raise the signal so Node's default exit code runs (e.g. 130 for SIGINT). |
|
|
382
|
-
| `beforeExit`
|
|
378
|
+
| `beforeExit` | Flush in place. Node continues its natural exit. |
|
|
383
379
|
|
|
384
380
|
### Default recommendation
|
|
385
381
|
|
|
386
382
|
`["SIGINT", "SIGTERM", "beforeExit"]` covers:
|
|
383
|
+
|
|
387
384
|
- Local `Ctrl+C` (SIGINT)
|
|
388
385
|
- Container orchestrators (`docker stop`, Kubernetes sending SIGTERM)
|
|
389
386
|
- Natural exit (Node finished all work)
|
|
@@ -402,7 +399,7 @@ Use this when you need async work (close an HTTP server, drain a queue) **before
|
|
|
402
399
|
async function gracefulShutdown() {
|
|
403
400
|
await httpServer.close();
|
|
404
401
|
await queue.drain();
|
|
405
|
-
log.flushSync();
|
|
402
|
+
log.flushSync(); // still sync — guarantees disk write before exit
|
|
406
403
|
process.exit(0);
|
|
407
404
|
}
|
|
408
405
|
|
|
@@ -428,13 +425,13 @@ process.once("SIGTERM", gracefulShutdown);
|
|
|
428
425
|
|
|
429
426
|
`log.flush()` drains every channel concurrently: each waits for its own async deliveries still in flight (level methods are fire-and-forget and never return a promise; entries logged during the flush are awaited too), then runs its `flush()`. A hung network delivery can't hold up the file channel, and one channel's flush rejecting neither aborts the others nor escapes as an unhandled rejection. It is bounded: `log.flush({ timeout })` resolves after `timeout` ms (default 5000) and reports each channel still draining to `onChannelError`.
|
|
430
427
|
|
|
431
|
-
|
|
|
432
|
-
|
|
433
|
-
| I/O
|
|
434
|
-
| Safe in a re-raising signal handler | yes
|
|
435
|
-
| Used by `autoFlushOn`
|
|
436
|
-
| Reach for it when
|
|
437
|
-
| `FileLog` / `JSONFileLog`
|
|
428
|
+
| | `flushSync()` | `flush()` |
|
|
429
|
+
| ----------------------------------- | ------------------------------------- | ------------------------------------------------------------ |
|
|
430
|
+
| I/O | synchronous — blocks the loop | asynchronous — awaited |
|
|
431
|
+
| Safe in a re-raising signal handler | yes | no — the signal exits before the promise settles |
|
|
432
|
+
| Used by `autoFlushOn` | yes | no |
|
|
433
|
+
| Reach for it when | file channels, last-resort durability | network/async channels, manual `await` before `process.exit` |
|
|
434
|
+
| `FileLog` / `JSONFileLog` | ✓ | ✓ (async write) |
|
|
438
435
|
|
|
439
436
|
`autoFlushOn` always uses `flushSync()` — signal re-raising can't wait on a promise. If a channel needs async delivery on shutdown, drive `await log.flush()` from your own handler and leave that signal out of `autoFlushOn`.
|
|
440
437
|
|
|
@@ -455,7 +452,7 @@ log.flushSync();
|
|
|
455
452
|
|
|
456
453
|
## Unhandled errors
|
|
457
454
|
|
|
458
|
-
The `uncaughtException` path in `captureAnyUnhandledRejection()` (the `capture-unhandled-errors` topic) already runs a best-effort, time-bounded `log.flush()` before its own `process.exit(1)`, so the fatal entry drains without extra wiring (and `process.exit()` skips `beforeExit`, so a `beforeExit` handler would not fire on that path anyway). Still set `autoFlushOn` for the
|
|
455
|
+
The `uncaughtException` path in `captureAnyUnhandledRejection()` (the `capture-unhandled-errors` topic) already runs a best-effort, time-bounded `log.flush()` before its own `process.exit(1)`, so the fatal entry drains without extra wiring (and `process.exit()` skips `beforeExit`, so a `beforeExit` handler would not fire on that path anyway). Still set `autoFlushOn` for the _other_ shutdown routes — `"SIGINT"` / `"SIGTERM"` and a natural `"beforeExit"` — so those don't lose the last buffered batch.
|
|
459
456
|
|
|
460
457
|
```ts
|
|
461
458
|
log.configure({
|
|
@@ -472,6 +469,24 @@ captureAnyUnhandledRejection();
|
|
|
472
469
|
- **Don't call `process.exit()` inside `autoFlushOn` handlers** — signal handlers here already re-raise the signal. Forcing an exit breaks exit codes.
|
|
473
470
|
- **Don't rely on the 5-second flush interval for shutdown safety.** It's a throughput optimization, not a durability guarantee.
|
|
474
471
|
|
|
472
|
+
## Observe channel failures and drain explicitly
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
import { log } from "@warlock.js/logger";
|
|
476
|
+
|
|
477
|
+
log.onChannelError((error, channel) => {
|
|
478
|
+
// Do not call this logger from its own failure handler.
|
|
479
|
+
console.error("Log delivery failed", channel.name, error);
|
|
480
|
+
});
|
|
481
|
+
log.info("app", "shutdown", "Draining logs");
|
|
482
|
+
await log.flush({ timeout: 3_000 });
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Level methods return the logger synchronously. `flush()` waits for async
|
|
486
|
+
channel work within its timeout, reports failures through `onChannelError`, and
|
|
487
|
+
resolves so shutdown can continue. Timeout does not cancel a channel's network
|
|
488
|
+
operation; its driver owns cancellation.
|
|
489
|
+
|
|
475
490
|
|
|
476
491
|
## logger-basics `@warlock.js/logger/logger-basics/SKILL.md`
|
|
477
492
|
|
|
@@ -514,10 +529,7 @@ The 11 things that are true in every logger use:
|
|
|
514
529
|
import { log, ConsoleLog, FileLog } from "@warlock.js/logger";
|
|
515
530
|
|
|
516
531
|
log.configure({
|
|
517
|
-
channels: [
|
|
518
|
-
new ConsoleLog(),
|
|
519
|
-
new FileLog({ chunk: "daily", storagePath: "./storage/logs" }),
|
|
520
|
-
],
|
|
532
|
+
channels: [new ConsoleLog(), new FileLog({ chunk: "daily", storagePath: "./storage/logs" })],
|
|
521
533
|
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
522
534
|
});
|
|
523
535
|
|
|
@@ -528,12 +540,12 @@ log.error("payments", "charge", new Error("Card declined"));
|
|
|
528
540
|
## The six levels
|
|
529
541
|
|
|
530
542
|
```ts
|
|
531
|
-
log.debug("module", "action", "verbose detail");
|
|
532
|
-
log.info("module", "action", "neutral event");
|
|
533
|
-
log.warn("module", "action", "something off");
|
|
534
|
-
log.error("module", "action", error);
|
|
535
|
-
log.success("module", "action", "operation done");
|
|
536
|
-
log.fatal("module", "action", error);
|
|
543
|
+
log.debug("module", "action", "verbose detail"); // dev-only diagnostics
|
|
544
|
+
log.info("module", "action", "neutral event"); // user-visible event
|
|
545
|
+
log.warn("module", "action", "something off"); // recoverable concern
|
|
546
|
+
log.error("module", "action", error); // handled failure, app continues
|
|
547
|
+
log.success("module", "action", "operation done"); // explicit success
|
|
548
|
+
log.fatal("module", "action", error); // unrecoverable, app is going down
|
|
537
549
|
```
|
|
538
550
|
|
|
539
551
|
`fatal` is purely informational — it does NOT auto-flush or exit. The caller decides whether to `await log.flush()` and `process.exit(...)`. See the `capture-unhandled-errors` topic for the `uncaughtException` → `fatal` routing.
|
|
@@ -542,17 +554,17 @@ Every call signature is the same — `module`, `action`, `message`, optional `co
|
|
|
542
554
|
|
|
543
555
|
## Pick a skill
|
|
544
556
|
|
|
545
|
-
| If the task is about…
|
|
546
|
-
|
|
|
547
|
-
| Picking a channel — what each built-in does, when to use which
|
|
548
|
-
| Startup — registering channels, environment-based setup, the `configure` method | the `configure-logger` topic
|
|
549
|
-
| Filtering log output (`levels`, `filter`, per-channel routing, `minLevel`)
|
|
550
|
-
| Graceful shutdown — `flushSync`, `autoFlushOn`, signal behavior
|
|
551
|
-
| Extending `LogChannel` to build a custom sink (Slack, database, HTTP)
|
|
552
|
-
| Routing Node's `unhandledRejection` / `uncaughtException` through the logger
|
|
553
|
-
| `log.assert(...)` and `log.timer(...)` shorthand helpers
|
|
554
|
-
| Redacting secrets — logger floor + additive channel paths
|
|
555
|
-
| Tests that assert on log output, or code under test that logs
|
|
557
|
+
| If the task is about… | Load |
|
|
558
|
+
| ------------------------------------------------------------------------------- | --------------------------------------- |
|
|
559
|
+
| Picking a channel — what each built-in does, when to use which | the `pick-log-channel` topic |
|
|
560
|
+
| Startup — registering channels, environment-based setup, the `configure` method | the `configure-logger` topic |
|
|
561
|
+
| Filtering log output (`levels`, `filter`, per-channel routing, `minLevel`) | the `filter-log-entries` topic |
|
|
562
|
+
| Graceful shutdown — `flushSync`, `autoFlushOn`, signal behavior | the `flush-logs-on-shutdown` topic |
|
|
563
|
+
| Extending `LogChannel` to build a custom sink (Slack, database, HTTP) | the `write-custom-log-channel` topic |
|
|
564
|
+
| Routing Node's `unhandledRejection` / `uncaughtException` through the logger | the `capture-unhandled-errors` topic |
|
|
565
|
+
| `log.assert(...)` and `log.timer(...)` shorthand helpers | the `use-log-helpers` topic |
|
|
566
|
+
| Redacting secrets — logger floor + additive channel paths | the `redact-sensitive-log-fields` topic |
|
|
567
|
+
| Tests that assert on log output, or code under test that logs | the `test-logging-code` topic |
|
|
556
568
|
|
|
557
569
|
## Things NOT to do
|
|
558
570
|
|
|
@@ -601,52 +613,63 @@ Eleven task skills cover everything. Load the one that matches your job — most
|
|
|
601
613
|
### Foundations
|
|
602
614
|
|
|
603
615
|
#### `logger-basics`
|
|
616
|
+
|
|
604
617
|
Start here. The `log` singleton, the six levels (`debug` / `info` / `warn` / `error` / `success` / `fatal`), how fan-out works, the `module / action / message / context` shape every entry carries.
|
|
605
618
|
|
|
606
619
|
#### `configure-logger`
|
|
620
|
+
|
|
607
621
|
Wire channels at boot — `log.addChannel`, `log.setChannels`, `log.configure({ channels, autoFlushOn, redact, minLevel })`. Branch on `NODE_ENV`, replace the channel list, isolate a library's logger from the host singleton.
|
|
608
622
|
|
|
609
623
|
### Channels
|
|
610
624
|
|
|
611
625
|
#### `pick-log-channel`
|
|
626
|
+
|
|
612
627
|
Pick one of the four built-ins: `ConsoleLog` (terminal, colored), `FileLog` (plain `.log` on disk with rotation), `JSONFileLog` (structured JSON for aggregators — Datadog, Loki, ELK), `SentryLog` (errors + breadcrumbs to Sentry; `@sentry/node` is an optional peer).
|
|
613
628
|
|
|
614
629
|
#### `ship-logs-to-sentry`
|
|
630
|
+
|
|
615
631
|
The `SentryLog` channel in depth — event-vs-breadcrumb level mapping, dual init modes (reuse an existing client or pass `options`), draining via `Sentry.flush(timeout)`, behavior when the optional peer isn't installed.
|
|
616
632
|
|
|
617
633
|
#### `write-custom-log-channel`
|
|
634
|
+
|
|
618
635
|
Extend `LogChannel<Options>` for sinks the built-ins don't cover — Slack, HTTP endpoint, in-memory buffer, database. The lazy `init()` lifecycle (`setTimeout(0)`), the `terminal: true/false` ANSI-stripping behavior, and the difference between `flush()` (async, network) and `flushSync()` (sync, files) are subtle — read this skill before subclassing.
|
|
619
636
|
|
|
620
637
|
### Production concerns
|
|
621
638
|
|
|
622
639
|
#### `redact-sensitive-log-fields`
|
|
640
|
+
|
|
623
641
|
Strip secrets before they reach a sink. Logger-wide `setRedact({ paths, censor })` is the security floor; per-channel `redact` configs add paths (never remove). Dotted-glob paths (`*`, `**`); censor as string or function `(value, path) => any`.
|
|
624
642
|
|
|
625
643
|
#### `filter-log-entries`
|
|
644
|
+
|
|
626
645
|
Drop entries before they cost anything. Logger-wide `setMinLevel("info")` is the fast path; per-channel `levels` array + `filter` predicate for fine control.
|
|
627
646
|
|
|
628
647
|
#### `flush-logs-on-shutdown`
|
|
648
|
+
|
|
629
649
|
Buffered channels need explicit drain. `log.flushSync()` (sync) for file channels — also wired by `enableAutoFlush(['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK', 'SIGUSR2', 'beforeExit'])`. `await log.flush()` (async) for network/async channels like `SentryLog` — the only path that can await an HTTPS round-trip on a graceful shutdown.
|
|
630
650
|
|
|
631
651
|
#### `capture-unhandled-errors`
|
|
652
|
+
|
|
632
653
|
`captureAnyUnhandledRejection()` hooks `unhandledRejection` (→ `log.error("app", ...)`, process kept alive) and `uncaughtException` (→ `log.fatal("app", ...)` then `process.exit(1)` — restoring the non-zero exit the listener would otherwise suppress, so a fatal crash never becomes a silent `exit 0`; opt out with `{ exitOnUncaughtException: false }`). It flushes before exiting and falls back to `console.error` when no terminal channel is set. One call at startup.
|
|
633
654
|
|
|
634
655
|
### Ergonomics + testing
|
|
635
656
|
|
|
636
657
|
#### `use-log-helpers`
|
|
658
|
+
|
|
637
659
|
Two shortcuts every `Logger` exposes: `log.assert(condition, module, action, message, context?)` logs an error only when the condition is falsy (free on the happy path); `log.timer(module, action)` returns an end-function that emits `info` with a measured `durationMs`.
|
|
638
660
|
|
|
639
661
|
#### `test-logging-code`
|
|
662
|
+
|
|
640
663
|
Silence the logger globally in tests via `log.setChannels([])` in `setupFiles`. Assert specific entries with a capturing `LogChannel` subclass — it proves an entry was actually delivered through the pipeline (filters, redaction), not merely that a method was called, and it isolates cleanly by swapping `log.channels`.
|
|
641
664
|
|
|
642
665
|
## Built-in channels at a glance
|
|
643
666
|
|
|
644
|
-
| Channel
|
|
645
|
-
|
|
|
646
|
-
| `ConsoleLog`
|
|
647
|
-
| `FileLog`
|
|
648
|
-
| `JSONFileLog` | `.json` files
|
|
649
|
-
| `SentryLog`
|
|
667
|
+
| Channel | Sink | `terminal` | Buffered |
|
|
668
|
+
| ------------- | --------------------------- | ----------------------- | ----------------------------------------------------------------------- |
|
|
669
|
+
| `ConsoleLog` | `process.stdout` | `true` (colors kept) | no |
|
|
670
|
+
| `FileLog` | `.log` files | `false` (ANSI stripped) | yes (5s timer or 100-entry buffer) |
|
|
671
|
+
| `JSONFileLog` | `.json` files | `false` (ANSI stripped) | yes (same buffering) |
|
|
672
|
+
| `SentryLog` | Sentry events + breadcrumbs | `false` | via the Sentry SDK's own transport (`@sentry/node` is an optional peer) |
|
|
650
673
|
|
|
651
674
|
`terminal: true` is the flag that decides whether the channel sees raw colored messages or stripped plain text. Custom channels: pick `true` if you write to a terminal, `false` for anything else.
|
|
652
675
|
|
|
@@ -676,12 +699,12 @@ Four built-in channels (`SentryLog` needs the optional `@sentry/node` peer). A c
|
|
|
676
699
|
|
|
677
700
|
## The decision
|
|
678
701
|
|
|
679
|
-
| Need
|
|
680
|
-
|
|
681
|
-
| Local dev, colored output in the terminal
|
|
682
|
-
| Plain text `.log` files on disk — humans read them
|
|
702
|
+
| Need | Pick |
|
|
703
|
+
| --------------------------------------------------------------------------------- | ------------- |
|
|
704
|
+
| Local dev, colored output in the terminal | `ConsoleLog` |
|
|
705
|
+
| Plain text `.log` files on disk — humans read them | `FileLog` |
|
|
683
706
|
| Structured `.json` files — a log aggregator (Loki / Datadog / Elastic) reads them | `JSONFileLog` |
|
|
684
|
-
| Errors & warnings into Sentry (events + breadcrumbs)
|
|
707
|
+
| Errors & warnings into Sentry (events + breadcrumbs) | `SentryLog` |
|
|
685
708
|
|
|
686
709
|
Most production setups use **two** channels: `ConsoleLog` + one file channel. Dev uses `ConsoleLog` only.
|
|
687
710
|
|
|
@@ -699,6 +722,7 @@ new ConsoleLog();
|
|
|
699
722
|
```
|
|
700
723
|
|
|
701
724
|
Properties:
|
|
725
|
+
|
|
702
726
|
- `name = "console"`, `terminal = true`
|
|
703
727
|
- Accepts `ConsoleLogConfig` — `levels`, `filter`, `dateFormat`, `showContext`, `contextDepth`
|
|
704
728
|
- If `message` is an object, a second `console.log(message)` is issued so Node's inspector can expand it
|
|
@@ -725,14 +749,26 @@ Plain text. Buffers in memory, flushes to disk periodically.
|
|
|
725
749
|
import { FileLog } from "@warlock.js/logger";
|
|
726
750
|
|
|
727
751
|
new FileLog({
|
|
728
|
-
storagePath: "./storage/logs",
|
|
729
|
-
name: "app",
|
|
730
|
-
extension: "log",
|
|
731
|
-
chunk: "daily",
|
|
732
|
-
rotate: true,
|
|
733
|
-
maxFileSize: 10 * 1024 * 1024,
|
|
734
|
-
maxMessagesToWrite: 100,
|
|
735
|
-
groupBy: ["level", "module"],
|
|
752
|
+
storagePath: "./storage/logs", // default: process.cwd() + "/storage/logs"
|
|
753
|
+
name: "app", // default: "app"
|
|
754
|
+
extension: "log", // default: "log"
|
|
755
|
+
chunk: "daily", // "single" (default) | "daily" | "hourly"
|
|
756
|
+
rotate: true, // default: true
|
|
757
|
+
maxFileSize: 10 * 1024 * 1024, // default: 10MB — triggers rotation
|
|
758
|
+
maxMessagesToWrite: 100, // default: 100 — flush threshold
|
|
759
|
+
groupBy: ["level", "module"], // optional subdirectory nesting
|
|
760
|
+
retention: { maxFiles: 14, maxAgeDays: 30 }, // optional pruning of old files
|
|
761
|
+
});
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
Name dated files yourself with `fileName` — a pattern (`{YYYY}` `{MM}` `{DD}` `{HH}` `{mm}` `{name}`, local time) or a function of the date:
|
|
765
|
+
|
|
766
|
+
```ts
|
|
767
|
+
new FileLog({ storagePath: "./storage/logs", fileName: "{name}-{YYYY}-{MM}-{DD}" }); // app-2026-10-17.log
|
|
768
|
+
new FileLog({
|
|
769
|
+
storagePath: "./storage/logs",
|
|
770
|
+
fileName: (date) => `audit-${date.getFullYear()}`,
|
|
771
|
+
retention: { maxFiles: 5, match: /^audit-\d{4}\.log$/ }, // a function fileName needs `match`
|
|
736
772
|
});
|
|
737
773
|
```
|
|
738
774
|
|
|
@@ -741,7 +777,8 @@ Line format: `[date time] [level] [module][action]: message` — or a `[trace]`
|
|
|
741
777
|
### Key gotchas
|
|
742
778
|
|
|
743
779
|
- **Buffers!** Messages sit in memory until either `maxMessagesToWrite` is reached, 5 seconds pass, or `flushSync()` is called. A process that crashes without flushing loses buffered entries.
|
|
744
|
-
- **`chunk: "daily"` picks a filename per day.** File name becomes `DD-MM-
|
|
780
|
+
- **`chunk: "daily"` picks a filename per day.** File name becomes `YYYY-MM-DD.log` (hourly: `YYYY-MM-DD-HH.log`), ignoring `name` unless `fileName` uses `{name}`. Since 5.31; keep the old `DD-MM-YYYY` with `fileName: "{DD}-{MM}-{YYYY}"`. Rotated archives are `<file name>-<rotateFileName>-<epoch ms>.<ext>`; `rotateFileName` is a pattern or function too (default `{YYYY}-{MM}-{DD}`).
|
|
781
|
+
- **`retention` only touches this channel's files.** On boot and whenever a new file starts it removes files in the log directory (each group directory with `groupBy`) whose names match the channel's pattern — beyond `maxFiles` (newest by modification time, the current file counts) or older than `maxAgeDays`. Failures go to `log.onChannelError(...)`, never to the call site. `await channel.prune()` runs it on demand.
|
|
745
782
|
- **`groupBy` nests directories.** `groupBy: ["level", "module"]` produces `storage/logs/error/payments/app.log`. Order matters.
|
|
746
783
|
- **Dispose channels you discard.** A live `FileLog` keeps a 5-second flush interval running. If you swap the channel list at runtime (reconfigure the logger), call `channel.dispose()` on the old instance — it clears that timer and drains the buffer one last time. Skipping it leaks one timer per discarded channel and keeps the event loop alive. (Channels that live for the whole process don't need this — process exit clears the timer.)
|
|
747
784
|
|
|
@@ -758,16 +795,14 @@ Subclass of `FileLog` — same buffering, chunking, rotation, grouping. Output i
|
|
|
758
795
|
"date": "15-03-2024 10:22:03",
|
|
759
796
|
"module": "payments",
|
|
760
797
|
"action": "charge",
|
|
761
|
-
"stack": [
|
|
762
|
-
"Error: Card declined",
|
|
763
|
-
" at chargeCard (/app/src/payments.ts:42:11)"
|
|
764
|
-
]
|
|
798
|
+
"stack": ["Error: Card declined", " at chargeCard (/app/src/payments.ts:42:11)"]
|
|
765
799
|
}
|
|
766
800
|
]
|
|
767
801
|
}
|
|
768
802
|
```
|
|
769
803
|
|
|
770
804
|
Differences from `FileLog`:
|
|
805
|
+
|
|
771
806
|
- `name = "fileJson"` (**not** `"json"` — use this exact string for `log.channel("fileJson")`)
|
|
772
807
|
- `extension` is always `"json"` — the option is silently ignored
|
|
773
808
|
- Error `stack` is stored as `string[]` (split on newlines) — easy to query in aggregators
|
|
@@ -794,8 +829,8 @@ Every channel constructor accepts at minimum:
|
|
|
794
829
|
|
|
795
830
|
```ts
|
|
796
831
|
type BasicLogConfigurations = {
|
|
797
|
-
levels?: LogLevel[];
|
|
798
|
-
filter?: (data: LoggingData) => boolean;
|
|
832
|
+
levels?: LogLevel[]; // whitelist — omit or [] to allow all
|
|
833
|
+
filter?: (data: LoggingData) => boolean; // custom predicate
|
|
799
834
|
dateFormat?: { date?: string; time?: string }; // Day.js format strings
|
|
800
835
|
context?: (data) => Promise<Record<string, any>>; // reserved — not yet read
|
|
801
836
|
};
|
|
@@ -806,10 +841,10 @@ Concrete file channels extend this with their storage/chunk/rotate/groupBy optio
|
|
|
806
841
|
## Picking a channel by name at runtime
|
|
807
842
|
|
|
808
843
|
```ts
|
|
809
|
-
log.channel("console");
|
|
810
|
-
log.channel("file");
|
|
811
|
-
log.channel("fileJson");
|
|
812
|
-
log.channel("sentry");
|
|
844
|
+
log.channel("console"); // → ConsoleLog | undefined
|
|
845
|
+
log.channel("file"); // → FileLog | undefined
|
|
846
|
+
log.channel("fileJson"); // ← note the name — NOT "json"
|
|
847
|
+
log.channel("sentry"); // → SentryLog | undefined
|
|
813
848
|
```
|
|
814
849
|
|
|
815
850
|
If two channels share a `name`, only one is reachable this way — the search returns the first match.
|
|
@@ -826,7 +861,7 @@ If two channels share a `name`, only one is reachable this way — the search re
|
|
|
826
861
|
|
|
827
862
|
---
|
|
828
863
|
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 — the `filter-log-entries` topic; custom sinks — the `write-custom-log-channel` topic; competing libs `pino.redact`, `fast-redact`.'
|
|
864
|
+
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`.'
|
|
830
865
|
---
|
|
831
866
|
|
|
832
867
|
# Redaction — keeping secrets out of logs
|
|
@@ -851,15 +886,15 @@ import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
|
|
|
851
886
|
|
|
852
887
|
log.configure({
|
|
853
888
|
redact: {
|
|
854
|
-
keys: ["internalRef"],
|
|
855
|
-
defaultKeys: false,
|
|
889
|
+
keys: ["internalRef"], // union with the built-in set
|
|
890
|
+
defaultKeys: false, // opt out of the built-in set entirely
|
|
856
891
|
},
|
|
857
892
|
});
|
|
858
893
|
```
|
|
859
894
|
|
|
860
895
|
`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
896
|
|
|
862
|
-
**A channel cannot turn the default set off** (it can only add keys, or turn it back
|
|
897
|
+
**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
898
|
|
|
864
899
|
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
900
|
|
|
@@ -880,18 +915,14 @@ import { log } from "@warlock.js/logger";
|
|
|
880
915
|
|
|
881
916
|
log.configure({
|
|
882
917
|
redact: {
|
|
883
|
-
paths: [
|
|
884
|
-
|
|
885
|
-
"context.*.token",
|
|
886
|
-
"context.headers.authorization",
|
|
887
|
-
],
|
|
888
|
-
censor: "[REDACTED]", // default — string or function
|
|
918
|
+
paths: ["context.password", "context.*.token", "context.headers.authorization"],
|
|
919
|
+
censor: "[REDACTED]", // default — string or function
|
|
889
920
|
},
|
|
890
921
|
});
|
|
891
922
|
|
|
892
923
|
// runtime equivalent:
|
|
893
924
|
log.setRedact({ paths: ["context.password"] });
|
|
894
|
-
log.setRedact(undefined);
|
|
925
|
+
log.setRedact(undefined); // clear your paths (the default denylist stays on)
|
|
895
926
|
```
|
|
896
927
|
|
|
897
928
|
Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
|
|
@@ -933,13 +964,13 @@ type LoggingData = {
|
|
|
933
964
|
};
|
|
934
965
|
```
|
|
935
966
|
|
|
936
|
-
| Pattern
|
|
937
|
-
|
|
|
938
|
-
| `context.password`
|
|
939
|
-
| `context.*.token`
|
|
940
|
-
| `**.password`
|
|
941
|
-
| `message.apiKey`
|
|
942
|
-
| `context.users.*.token` | array element redaction (`*` matches indices too)
|
|
967
|
+
| Pattern | Matches |
|
|
968
|
+
| ----------------------- | ------------------------------------------------------------------ |
|
|
969
|
+
| `context.password` | exactly `data.context.password` |
|
|
970
|
+
| `context.*.token` | `data.context.<any>.token` (one segment in between) |
|
|
971
|
+
| `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
|
|
972
|
+
| `message.apiKey` | when message is an object, `data.message.apiKey` |
|
|
973
|
+
| `context.users.*.token` | array element redaction (`*` matches indices too) |
|
|
943
974
|
|
|
944
975
|
Wildcards:
|
|
945
976
|
|
|
@@ -981,6 +1012,40 @@ If `message` is a plain object, paths under `message.*` work as expected. If `me
|
|
|
981
1012
|
|
|
982
1013
|
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
1014
|
|
|
1015
|
+
## Email addresses inside strings
|
|
1016
|
+
|
|
1017
|
+
```ts
|
|
1018
|
+
import { log, maskEmail, maskEmailsInText } from "@warlock.js/logger";
|
|
1019
|
+
|
|
1020
|
+
log.info("mail", "send", `Sending to ${maskEmail("member@example.com")}`);
|
|
1021
|
+
log.debug("cache", "key", maskEmailsInText("profile:member@example.com"));
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Key-based redaction cannot mask a substring inside a message or cache key.
|
|
1025
|
+
`maskEmail()` keeps the first code point and domain (`m***@example.com`);
|
|
1026
|
+
`maskEmailsInText()` handles text and bounds its scan at 16,384 characters,
|
|
1027
|
+
replacing the remaining suffix with a truncation marker.
|
|
1028
|
+
|
|
1029
|
+
## Client text inside a message: `escapeLogValue()`
|
|
1030
|
+
|
|
1031
|
+
A user agent or search term with a line break forges an extra entry in console
|
|
1032
|
+
and file logs; an ANSI sequence rewrites the terminal. Escape it first:
|
|
1033
|
+
|
|
1034
|
+
```ts
|
|
1035
|
+
import { escapeLogValue, log } from "@warlock.js/logger";
|
|
1036
|
+
|
|
1037
|
+
export function logSearch(term: string, userAgent: string) {
|
|
1038
|
+
log.info("search", "query", `"${escapeLogValue(term, { maxLength: 200 })}"`, {
|
|
1039
|
+
agent: escapeLogValue(userAgent),
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
CR/LF/tab become `\r`/`\n`/`\t`, other control characters `\uXXXX`, ANSI
|
|
1045
|
+
sequences are removed; `maxLength` cuts and marks `…[truncated]`. `warlock
|
|
1046
|
+
check` (from `@warlock.js/core`) reports raw emails, credentials and request
|
|
1047
|
+
bodies in log calls as `WL9008` and accepts these helpers.
|
|
1048
|
+
|
|
984
1049
|
|
|
985
1050
|
## ship-logs-to-sentry `@warlock.js/logger/ship-logs-to-sentry/SKILL.md`
|
|
986
1051
|
|
|
@@ -1036,14 +1101,14 @@ With neither `client` nor `options`, the channel reuses whatever global Sentry c
|
|
|
1036
1101
|
|
|
1037
1102
|
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.
|
|
1038
1103
|
|
|
1039
|
-
| Logger level | Default
|
|
1040
|
-
|
|
1041
|
-
| `fatal`
|
|
1042
|
-
| `error`
|
|
1043
|
-
| `warn`
|
|
1044
|
-
| `success`
|
|
1045
|
-
| `info`
|
|
1046
|
-
| `debug`
|
|
1104
|
+
| Logger level | Default | Sentry call |
|
|
1105
|
+
| ------------ | ---------- | ---------------------------------------------------------------------------- |
|
|
1106
|
+
| `fatal` | event | `captureException` for an `Error` message, else `captureMessage(…, "fatal")` |
|
|
1107
|
+
| `error` | event | `captureException` for an `Error` message, else `captureMessage(…, "error")` |
|
|
1108
|
+
| `warn` | event | `captureMessage(…, "warning")` |
|
|
1109
|
+
| `success` | breadcrumb | `addBreadcrumb({ level: "info" })` |
|
|
1110
|
+
| `info` | breadcrumb | `addBreadcrumb({ level: "info" })` |
|
|
1111
|
+
| `debug` | breadcrumb | `addBreadcrumb({ level: "debug" })` |
|
|
1047
1112
|
|
|
1048
1113
|
- **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.
|
|
1049
1114
|
- **`module` / `action` become tags** and the entry's `context` becomes a structured Sentry context, both scoped to that single event via `withScope`.
|
|
@@ -1068,14 +1133,14 @@ Sentry sends events asynchronously over the network, so a synchronous flush can'
|
|
|
1068
1133
|
```ts
|
|
1069
1134
|
async function shutdown() {
|
|
1070
1135
|
await httpServer.close();
|
|
1071
|
-
await log.flush();
|
|
1136
|
+
await log.flush(); // SentryLog.flush() → Sentry.flush(flushTimeout)
|
|
1072
1137
|
process.exit(0);
|
|
1073
1138
|
}
|
|
1074
1139
|
|
|
1075
1140
|
process.once("SIGTERM", shutdown);
|
|
1076
1141
|
```
|
|
1077
1142
|
|
|
1078
|
-
`flushTimeout` (default `2000` ms) bounds the wait so an unreachable Sentry can't hang shutdown. `autoFlushOn` uses the **synchronous** `flushSync()`, which does
|
|
1143
|
+
`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.
|
|
1079
1144
|
|
|
1080
1145
|
## If `@sentry/node` isn't installed
|
|
1081
1146
|
|
|
@@ -1083,17 +1148,17 @@ The channel never crashes your app: the dynamic import failure is swallowed, the
|
|
|
1083
1148
|
|
|
1084
1149
|
## Config reference
|
|
1085
1150
|
|
|
1086
|
-
| Option
|
|
1087
|
-
|
|
1088
|
-
| `client`
|
|
1089
|
-
| `options`
|
|
1090
|
-
| `eventLevels`
|
|
1091
|
-
| `flushTimeout`
|
|
1092
|
-
| `levels`, `filter`, `dateFormat`, `redact` | —
|
|
1151
|
+
| Option | Type | Default | Description |
|
|
1152
|
+
| ------------------------------------------ | ---------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
1153
|
+
| `client` | Sentry namespace / forwarder | — | Reuse an already-initialized Sentry instance. |
|
|
1154
|
+
| `options` | `SentryInitOptions` | — | Sentry init options (mirrors `@sentry/node`'s `NodeOptions`) — used when the channel owns Sentry. |
|
|
1155
|
+
| `eventLevels` | `LogLevel[]` | `["fatal", "error", "warn"]` | Levels sent as events; the rest become breadcrumbs. |
|
|
1156
|
+
| `flushTimeout` | `number` | `2000` | Ms `flush()` waits for the transport to drain. |
|
|
1157
|
+
| `levels`, `filter`, `dateFormat`, `redact` | — | — | Inherited from `BasicLogConfigurations`. |
|
|
1093
1158
|
|
|
1094
1159
|
## Don't do
|
|
1095
1160
|
|
|
1096
|
-
- **Don't double-init Sentry.** Pass
|
|
1161
|
+
- **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.
|
|
1097
1162
|
- **Don't send every level as an event.** `eventLevels` with `info`/`debug`/`success` will flood your Sentry quota — keep them as breadcrumbs.
|
|
1098
1163
|
- **Don't rely on `autoFlushOn` for Sentry.** It's synchronous; a network channel needs `await log.flush()`.
|
|
1099
1164
|
|
|
@@ -1149,7 +1214,9 @@ class CapturingChannel extends LogChannel {
|
|
|
1149
1214
|
public name = "capture";
|
|
1150
1215
|
public terminal = false;
|
|
1151
1216
|
public received: LoggingData[] = [];
|
|
1152
|
-
public log(data: LoggingData) {
|
|
1217
|
+
public log(data: LoggingData) {
|
|
1218
|
+
this.received.push({ ...data });
|
|
1219
|
+
}
|
|
1153
1220
|
}
|
|
1154
1221
|
```
|
|
1155
1222
|
|
|
@@ -1208,7 +1275,7 @@ No cleanup needed — the local `Logger` is garbage-collected.
|
|
|
1208
1275
|
|
|
1209
1276
|
## Why not spy on `log.info`?
|
|
1210
1277
|
|
|
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")`
|
|
1278
|
+
`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
1279
|
|
|
1213
1280
|
- 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
1281
|
- 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.
|
|
@@ -1378,6 +1445,7 @@ export class NullChannel extends LogChannel {
|
|
|
1378
1445
|
```
|
|
1379
1446
|
|
|
1380
1447
|
Then:
|
|
1448
|
+
|
|
1381
1449
|
```ts
|
|
1382
1450
|
log.addChannel(new NullChannel());
|
|
1383
1451
|
```
|
|
@@ -1386,16 +1454,16 @@ That's a working channel. `LogChannel` provides the scaffolding; you only need t
|
|
|
1386
1454
|
|
|
1387
1455
|
## What `LogChannel` gives you
|
|
1388
1456
|
|
|
1389
|
-
| Thing
|
|
1390
|
-
|
|
1391
|
-
| `name`, `description`, `terminal` | You (fields on your subclass)
|
|
1392
|
-
| `log(data)`
|
|
1393
|
-
| `flushSync()`
|
|
1394
|
-
| `flush()`
|
|
1395
|
-
| `init()`
|
|
1396
|
-
| `shouldBeLogged(data)`
|
|
1397
|
-
| `config<K>(key)`
|
|
1398
|
-
| `getDateAndTimeFormat()`
|
|
1457
|
+
| Thing | Who provides it |
|
|
1458
|
+
| --------------------------------- | -------------------------------------------------------------- |
|
|
1459
|
+
| `name`, `description`, `terminal` | You (fields on your subclass) |
|
|
1460
|
+
| `log(data)` | **You must implement** — abstract |
|
|
1461
|
+
| `flushSync()` | You (optional sync drain — only if you buffer) |
|
|
1462
|
+
| `flush()` | You (optional async drain — only if you buffer over async I/O) |
|
|
1463
|
+
| `init()` | You (optional async hook — see below) |
|
|
1464
|
+
| `shouldBeLogged(data)` | `LogChannel` — combines `levels` + `filter` |
|
|
1465
|
+
| `config<K>(key)` | `LogChannel` — merges user config with `defaultConfigurations` |
|
|
1466
|
+
| `getDateAndTimeFormat()` | `LogChannel` — returns resolved `dateFormat` |
|
|
1399
1467
|
|
|
1400
1468
|
## Complete example — SlackLog
|
|
1401
1469
|
|
|
@@ -1413,7 +1481,7 @@ export class SlackLog extends LogChannel<SlackConfig> {
|
|
|
1413
1481
|
public description = "Posts errors + warnings to a Slack webhook";
|
|
1414
1482
|
|
|
1415
1483
|
public async log(data: LoggingData) {
|
|
1416
|
-
if (!this.shouldBeLogged(data)) return;
|
|
1484
|
+
if (!this.shouldBeLogged(data)) return; // ← inherit levels + filter
|
|
1417
1485
|
|
|
1418
1486
|
await fetch(this.config("webhookUrl"), {
|
|
1419
1487
|
method: "POST",
|
|
@@ -1444,9 +1512,7 @@ log.setChannels([
|
|
|
1444
1512
|
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.
|
|
1445
1513
|
|
|
1446
1514
|
```ts
|
|
1447
|
-
export class DatabaseLog extends LogChannel<
|
|
1448
|
-
BasicLogConfigurations & { connectionString: string }
|
|
1449
|
-
> {
|
|
1515
|
+
export class DatabaseLog extends LogChannel<BasicLogConfigurations & { connectionString: string }> {
|
|
1450
1516
|
public name = "database";
|
|
1451
1517
|
private client!: SomeDbClient;
|
|
1452
1518
|
|
|
@@ -1461,6 +1527,38 @@ export class DatabaseLog extends LogChannel<
|
|
|
1461
1527
|
}
|
|
1462
1528
|
```
|
|
1463
1529
|
|
|
1530
|
+
## Reporting background failures — `reportError()`
|
|
1531
|
+
|
|
1532
|
+
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.
|
|
1533
|
+
|
|
1534
|
+
```ts
|
|
1535
|
+
export class QueueLog extends LogChannel {
|
|
1536
|
+
public name = "queue";
|
|
1537
|
+
|
|
1538
|
+
protected init() {
|
|
1539
|
+
setInterval(() => {
|
|
1540
|
+
this.drain().catch((error) =>
|
|
1541
|
+
this.reportError(error, {
|
|
1542
|
+
type: "error",
|
|
1543
|
+
module: "logger",
|
|
1544
|
+
action: "drain",
|
|
1545
|
+
message: "Queue drain failed",
|
|
1546
|
+
}),
|
|
1547
|
+
);
|
|
1548
|
+
}, 1_000);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
public log(data: LoggingData) {
|
|
1552
|
+
if (!this.shouldBeLogged(data)) return;
|
|
1553
|
+
// enqueue data …
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private async drain() {
|
|
1557
|
+
// send queued entries …
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
```
|
|
1561
|
+
|
|
1464
1562
|
## Implementing `flushSync()`
|
|
1465
1563
|
|
|
1466
1564
|
Only if your channel buffers. Signature: `flushSync?(): void`. Synchronous — no `await`, no promises.
|
|
@@ -1482,7 +1580,9 @@ export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: str
|
|
|
1482
1580
|
// so a follow-up async drain can recover it next boot.
|
|
1483
1581
|
}
|
|
1484
1582
|
|
|
1485
|
-
private async drain() {
|
|
1583
|
+
private async drain() {
|
|
1584
|
+
/* async post to this.config("url") */
|
|
1585
|
+
}
|
|
1486
1586
|
}
|
|
1487
1587
|
```
|
|
1488
1588
|
|
|
@@ -1502,7 +1602,7 @@ export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: str
|
|
|
1502
1602
|
}
|
|
1503
1603
|
|
|
1504
1604
|
public async flush() {
|
|
1505
|
-
await this.drain();
|
|
1605
|
+
await this.drain(); // awaited by log.flush() on shutdown
|
|
1506
1606
|
}
|
|
1507
1607
|
|
|
1508
1608
|
private async drain() {
|
|
@@ -1534,7 +1634,7 @@ class MinimalSlack implements LogContract {
|
|
|
1534
1634
|
|
|
1535
1635
|
public async log(data: LoggingData) {
|
|
1536
1636
|
if (data.type !== "error") return;
|
|
1537
|
-
await fetch(process.env.SLACK_WEBHOOK!, {
|
|
1637
|
+
await fetch(process.env.SLACK_WEBHOOK!, {/* ... */});
|
|
1538
1638
|
}
|
|
1539
1639
|
}
|
|
1540
1640
|
```
|