@warlock.js/logger 5.23.2 → 5.25.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 +12 -0
- package/README.md +153 -153
- package/cjs/index.cjs.map +1 -1
- package/esm/channels/console-log.mjs.map +1 -1
- package/esm/channels/file-log.mjs.map +1 -1
- package/esm/channels/json-file-log.mjs.map +1 -1
- package/esm/channels/sentry-log.mjs.map +1 -1
- package/esm/log-channel.mjs.map +1 -1
- package/esm/logger.mjs.map +1 -1
- package/esm/utils/capture-unhandled-errors.mjs.map +1 -1
- package/esm/utils/clear-message.mjs.map +1 -1
- package/llms-full.txt +1413 -1413
- package/package.json +2 -2
- package/skills/capture-unhandled-errors/SKILL.md +111 -111
- package/skills/configure-logger/SKILL.md +105 -105
- package/skills/filter-log-entries/SKILL.md +120 -120
- package/skills/flush-logs-on-shutdown/SKILL.md +117 -117
- package/skills/overview/SKILL.md +93 -93
- package/skills/pick-log-channel/SKILL.md +155 -155
- package/skills/redact-sensitive-log-fields/SKILL.md +156 -156
- package/skills/ship-logs-to-sentry/SKILL.md +118 -118
- package/skills/test-logging-code/SKILL.md +177 -177
- package/skills/use-log-helpers/SKILL.md +66 -66
- package/skills/write-custom-log-channel/SKILL.md +190 -190
package/llms-full.txt
CHANGED
|
@@ -6,471 +6,471 @@
|
|
|
6
6
|
|
|
7
7
|
## capture-unhandled-errors `@warlock.js/logger/capture-unhandled-errors/SKILL.md`
|
|
8
8
|
|
|
9
|
-
---
|
|
10
|
-
name: capture-unhandled-errors
|
|
11
|
-
description: 'captureAnyUnhandledRejection() installs process.on(''unhandledRejection'') → log.error and process.on(''uncaughtException'') → log.fatal + process.exit(1) so process-level failures land in your channels and a fatal crash is never silently swallowed into exit 0. Triggers: `captureAnyUnhandledRejection`, `exitOnUncaughtException`, `unhandledRejection`, `uncaughtException`, `log.error`, `log.fatal`; "log unhandled promise rejections", "catch uncaught exceptions to a file", "record crashes before exit", "server exits 0 with no error", "silent exit / production server stopped", "global error handler with logger"; typical import `import { captureAnyUnhandledRejection, log } from "@warlock.js/logger"`. Skip: flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `Sentry.init`, `@sentry/node`; native `process.on(''unhandledRejection'')`.'
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
# Error capture — routing Node's unhandled errors through the logger
|
|
15
|
-
|
|
16
|
-
`captureAnyUnhandledRejection()` installs two process-level listeners so crashes are logged — and, for an `uncaughtException`, made loud and terminal — instead of being silently swallowed.
|
|
17
|
-
|
|
18
|
-
## What it does
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
import { captureAnyUnhandledRejection } from "@warlock.js/logger";
|
|
22
|
-
|
|
23
|
-
captureAnyUnhandledRejection();
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Registers:
|
|
27
|
-
- `process.on("unhandledRejection", reason => log.error("app", "unhandledRejection", reason))` — logged; the process is kept alive.
|
|
28
|
-
- `process.on("uncaughtException", error => log.fatal("app", "uncaughtException", error))` — logged, then `process.exit(1)` (by default).
|
|
29
|
-
|
|
30
|
-
The split is intentional: an `uncaughtException` leaves the process in an undefined state, so it's semantically `fatal` and takes the process down. An `unhandledRejection` is a failure but not always process-ending (depends on Node's `--unhandled-rejections` policy and your app's recovery), so it stays at `error` and never exits. This makes "page on fatal" alerting clean — only true crashes ring the pager.
|
|
31
|
-
|
|
32
|
-
## Why it exits (and why that matters)
|
|
33
|
-
|
|
34
|
-
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
|
-
- **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
|
-
- **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).
|
|
38
|
-
|
|
39
|
-
> The framework wires this for you in `bootstrap()` as `captureAnyUnhandledRejection({ exitOnUncaughtException: Application.isProduction })` — production crashes loudly and non-zero, the dev server logs-and-continues so HMR can recover.
|
|
40
|
-
|
|
41
|
-
## When to call it
|
|
42
|
-
|
|
43
|
-
**Once**, at startup, **after** channels are registered. Typical place: immediately after your `log.configure({...})` call.
|
|
44
|
-
|
|
45
|
-
```ts title="src/index.ts"
|
|
46
|
-
import {
|
|
47
|
-
log,
|
|
48
|
-
ConsoleLog,
|
|
49
|
-
FileLog,
|
|
50
|
-
captureAnyUnhandledRejection,
|
|
51
|
-
} from "@warlock.js/logger";
|
|
52
|
-
|
|
53
|
-
log.configure({
|
|
54
|
-
channels: [new ConsoleLog(), new FileLog({ levels: ["error"] })],
|
|
55
|
-
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"], // ← important; see below
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
captureAnyUnhandledRejection();
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## Flushing on the crash path
|
|
62
|
-
|
|
63
|
-
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
|
-
|
|
65
|
-
`"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 [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md).
|
|
66
|
-
|
|
67
|
-
## Idempotency — don't call it twice
|
|
68
|
-
|
|
69
|
-
Calling `captureAnyUnhandledRejection()` a second time registers a second pair of listeners. Your next rejection gets logged twice. There's no dedup; just call it once.
|
|
70
|
-
|
|
71
|
-
## What it does **not** do
|
|
72
|
-
|
|
73
|
-
- **Does not swallow a fatal error.** After an `uncaughtException` it records the error, then exits non-zero itself — restoring Node's own default, which merely registering the listener would otherwise suppress. Pass `{ exitOnUncaughtException: false }` only when the process is meant to survive (e.g. HMR).
|
|
74
|
-
- **Does not exit on an `unhandledRejection`.** That path only logs (at `error`); the process keeps running. Set Node's `--unhandled-rejections=throw` if you want a rejection to escalate to an `uncaughtException`.
|
|
75
|
-
- **Does not install Node's `--unhandled-rejections` policy.** That's a Node flag; set it in your launch script if you want strict mode.
|
|
76
|
-
- **Does not hook `SIGTERM` / `SIGINT`** — use `enableAutoFlush` for signal flushes.
|
|
77
|
-
- **Does not filter.** Every rejection is logged at `error` and every uncaught exception at `fatal`, both with `module: "app"`. Filter per-channel if some noise slips in.
|
|
78
|
-
|
|
79
|
-
## Checking an error was captured in tests
|
|
80
|
-
|
|
81
|
-
Don't mock `process.on` — use a capturing channel and emit the listener directly:
|
|
82
|
-
|
|
83
|
-
```ts
|
|
84
|
-
import { log, captureAnyUnhandledRejection, LogChannel } from "@warlock.js/logger";
|
|
85
|
-
import type { LoggingData } from "@warlock.js/logger";
|
|
86
|
-
|
|
87
|
-
class Capture extends LogChannel {
|
|
88
|
-
public name = "capture";
|
|
89
|
-
public received: LoggingData[] = [];
|
|
90
|
-
public log(data: LoggingData) { this.received.push({ ...data }); }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
it("routes unhandled rejections to the logger", async () => {
|
|
94
|
-
const capture = new Capture();
|
|
95
|
-
const originalChannels = log.channels;
|
|
96
|
-
log.channels = [capture];
|
|
97
|
-
|
|
98
|
-
captureAnyUnhandledRejection();
|
|
99
|
-
process.emit("unhandledRejection", new Error("boom"), Promise.resolve());
|
|
100
|
-
|
|
101
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
102
|
-
|
|
103
|
-
expect(capture.received[0]!.module).toBe("app");
|
|
104
|
-
expect(capture.received[0]!.action).toBe("unhandledRejection");
|
|
105
|
-
|
|
106
|
-
log.channels = originalChannels;
|
|
107
|
-
});
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
Testing the **`uncaughtException`** path additionally trips `process.exit(1)`, so stub it (`vi.spyOn(process, "exit").mockImplementation(() => undefined as never)`) or pass `{ exitOnUncaughtException: false }` — otherwise the emitted exception tears the test runner down. See [`@warlock.js/logger/test-logging-code/SKILL.md`](@warlock.js/logger/test-logging-code/SKILL.md).
|
|
111
|
-
|
|
112
|
-
## Module + action the capture uses
|
|
113
|
-
|
|
114
|
-
Both listeners log with:
|
|
115
|
-
- `module: "app"`
|
|
116
|
-
- `action: "unhandledRejection"` (at `error`) or `action: "uncaughtException"` (at `fatal`)
|
|
117
|
-
- `message`: the rejection reason / exception (keep it as the raw `Error` object — file channels capture the stack).
|
|
118
|
-
|
|
119
|
-
If you want these routed to a specific file, filter on `data.module === "app"`. See [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md).
|
|
9
|
+
---
|
|
10
|
+
name: capture-unhandled-errors
|
|
11
|
+
description: 'captureAnyUnhandledRejection() installs process.on(''unhandledRejection'') → log.error and process.on(''uncaughtException'') → log.fatal + process.exit(1) so process-level failures land in your channels and a fatal crash is never silently swallowed into exit 0. Triggers: `captureAnyUnhandledRejection`, `exitOnUncaughtException`, `unhandledRejection`, `uncaughtException`, `log.error`, `log.fatal`; "log unhandled promise rejections", "catch uncaught exceptions to a file", "record crashes before exit", "server exits 0 with no error", "silent exit / production server stopped", "global error handler with logger"; typical import `import { captureAnyUnhandledRejection, log } from "@warlock.js/logger"`. Skip: flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `Sentry.init`, `@sentry/node`; native `process.on(''unhandledRejection'')`.'
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Error capture — routing Node's unhandled errors through the logger
|
|
15
|
+
|
|
16
|
+
`captureAnyUnhandledRejection()` installs two process-level listeners so crashes are logged — and, for an `uncaughtException`, made loud and terminal — instead of being silently swallowed.
|
|
17
|
+
|
|
18
|
+
## What it does
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { captureAnyUnhandledRejection } from "@warlock.js/logger";
|
|
22
|
+
|
|
23
|
+
captureAnyUnhandledRejection();
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Registers:
|
|
27
|
+
- `process.on("unhandledRejection", reason => log.error("app", "unhandledRejection", reason))` — logged; the process is kept alive.
|
|
28
|
+
- `process.on("uncaughtException", error => log.fatal("app", "uncaughtException", error))` — logged, then `process.exit(1)` (by default).
|
|
29
|
+
|
|
30
|
+
The split is intentional: an `uncaughtException` leaves the process in an undefined state, so it's semantically `fatal` and takes the process down. An `unhandledRejection` is a failure but not always process-ending (depends on Node's `--unhandled-rejections` policy and your app's recovery), so it stays at `error` and never exits. This makes "page on fatal" alerting clean — only true crashes ring the pager.
|
|
31
|
+
|
|
32
|
+
## Why it exits (and why that matters)
|
|
33
|
+
|
|
34
|
+
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
|
+
- **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
|
+
- **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).
|
|
38
|
+
|
|
39
|
+
> The framework wires this for you in `bootstrap()` as `captureAnyUnhandledRejection({ exitOnUncaughtException: Application.isProduction })` — production crashes loudly and non-zero, the dev server logs-and-continues so HMR can recover.
|
|
40
|
+
|
|
41
|
+
## When to call it
|
|
42
|
+
|
|
43
|
+
**Once**, at startup, **after** channels are registered. Typical place: immediately after your `log.configure({...})` call.
|
|
44
|
+
|
|
45
|
+
```ts title="src/index.ts"
|
|
46
|
+
import {
|
|
47
|
+
log,
|
|
48
|
+
ConsoleLog,
|
|
49
|
+
FileLog,
|
|
50
|
+
captureAnyUnhandledRejection,
|
|
51
|
+
} from "@warlock.js/logger";
|
|
52
|
+
|
|
53
|
+
log.configure({
|
|
54
|
+
channels: [new ConsoleLog(), new FileLog({ levels: ["error"] })],
|
|
55
|
+
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"], // ← important; see below
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
captureAnyUnhandledRejection();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Flushing on the crash path
|
|
62
|
+
|
|
63
|
+
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
|
+
|
|
65
|
+
`"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 [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md).
|
|
66
|
+
|
|
67
|
+
## Idempotency — don't call it twice
|
|
68
|
+
|
|
69
|
+
Calling `captureAnyUnhandledRejection()` a second time registers a second pair of listeners. Your next rejection gets logged twice. There's no dedup; just call it once.
|
|
70
|
+
|
|
71
|
+
## What it does **not** do
|
|
72
|
+
|
|
73
|
+
- **Does not swallow a fatal error.** After an `uncaughtException` it records the error, then exits non-zero itself — restoring Node's own default, which merely registering the listener would otherwise suppress. Pass `{ exitOnUncaughtException: false }` only when the process is meant to survive (e.g. HMR).
|
|
74
|
+
- **Does not exit on an `unhandledRejection`.** That path only logs (at `error`); the process keeps running. Set Node's `--unhandled-rejections=throw` if you want a rejection to escalate to an `uncaughtException`.
|
|
75
|
+
- **Does not install Node's `--unhandled-rejections` policy.** That's a Node flag; set it in your launch script if you want strict mode.
|
|
76
|
+
- **Does not hook `SIGTERM` / `SIGINT`** — use `enableAutoFlush` for signal flushes.
|
|
77
|
+
- **Does not filter.** Every rejection is logged at `error` and every uncaught exception at `fatal`, both with `module: "app"`. Filter per-channel if some noise slips in.
|
|
78
|
+
|
|
79
|
+
## Checking an error was captured in tests
|
|
80
|
+
|
|
81
|
+
Don't mock `process.on` — use a capturing channel and emit the listener directly:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { log, captureAnyUnhandledRejection, LogChannel } from "@warlock.js/logger";
|
|
85
|
+
import type { LoggingData } from "@warlock.js/logger";
|
|
86
|
+
|
|
87
|
+
class Capture extends LogChannel {
|
|
88
|
+
public name = "capture";
|
|
89
|
+
public received: LoggingData[] = [];
|
|
90
|
+
public log(data: LoggingData) { this.received.push({ ...data }); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
it("routes unhandled rejections to the logger", async () => {
|
|
94
|
+
const capture = new Capture();
|
|
95
|
+
const originalChannels = log.channels;
|
|
96
|
+
log.channels = [capture];
|
|
97
|
+
|
|
98
|
+
captureAnyUnhandledRejection();
|
|
99
|
+
process.emit("unhandledRejection", new Error("boom"), Promise.resolve());
|
|
100
|
+
|
|
101
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
102
|
+
|
|
103
|
+
expect(capture.received[0]!.module).toBe("app");
|
|
104
|
+
expect(capture.received[0]!.action).toBe("unhandledRejection");
|
|
105
|
+
|
|
106
|
+
log.channels = originalChannels;
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Testing the **`uncaughtException`** path additionally trips `process.exit(1)`, so stub it (`vi.spyOn(process, "exit").mockImplementation(() => undefined as never)`) or pass `{ exitOnUncaughtException: false }` — otherwise the emitted exception tears the test runner down. See [`@warlock.js/logger/test-logging-code/SKILL.md`](@warlock.js/logger/test-logging-code/SKILL.md).
|
|
111
|
+
|
|
112
|
+
## Module + action the capture uses
|
|
113
|
+
|
|
114
|
+
Both listeners log with:
|
|
115
|
+
- `module: "app"`
|
|
116
|
+
- `action: "unhandledRejection"` (at `error`) or `action: "uncaughtException"` (at `fatal`)
|
|
117
|
+
- `message`: the rejection reason / exception (keep it as the raw `Error` object — file channels capture the stack).
|
|
118
|
+
|
|
119
|
+
If you want these routed to a specific file, filter on `data.module === "app"`. See [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md).
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
## configure-logger `@warlock.js/logger/configure-logger/SKILL.md`
|
|
123
123
|
|
|
124
|
-
---
|
|
125
|
-
name: configure-logger
|
|
126
|
-
description: 'Register channels via log.addChannel / log.setChannels / log.configure({channels, autoFlushOn, redact, minLevel}) at boot. Triggers: `log.configure`, `log.addChannel`, `log.setChannels`, `Logger`, `autoFlushOn`, `disableAutoFlush`; "wire channels at startup", "branch logger by NODE_ENV", "isolate a library''s logger", "replace channel list"; typical import `import { log, Logger, ConsoleLog, FileLog } from "@warlock.js/logger"`. Skip: channel picks — `@warlock.js/logger/pick-log-channel/SKILL.md`; flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; redaction — `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`; competing libs `winston.createLogger`, `pino`.'
|
|
127
|
-
---
|
|
128
|
-
|
|
129
|
-
# Setup — registering channels at startup
|
|
130
|
-
|
|
131
|
-
The logger is a singleton. Do all setup in one place, as early in the app entry point as possible.
|
|
132
|
-
|
|
133
|
-
## The three channel-registration methods
|
|
134
|
-
|
|
135
|
-
| Method | Semantics |
|
|
136
|
-
|---|---|
|
|
137
|
-
| `log.addChannel(channel)` | **Appends.** Safe to call multiple times. |
|
|
138
|
-
| `log.setChannels([...])` | **Replaces** the full list. |
|
|
139
|
-
| `log.configure({ channels, autoFlushOn, redact, minLevel })` | **Replaces** channels if provided; installs auto-flush if provided; sets redact / minLevel if provided. All four are optional. |
|
|
140
|
-
|
|
141
|
-
All three return `this` — chainable.
|
|
142
|
-
|
|
143
|
-
## Recommended pattern — one dedicated file
|
|
144
|
-
|
|
145
|
-
```ts title="src/logger.ts"
|
|
146
|
-
import { log, ConsoleLog, FileLog, JSONFileLog } from "@warlock.js/logger";
|
|
147
|
-
|
|
148
|
-
if (process.env.NODE_ENV === "production") {
|
|
149
|
-
log.configure({
|
|
150
|
-
channels: [
|
|
151
|
-
new FileLog({ storagePath: "./storage/logs", chunk: "daily", rotate: true }),
|
|
152
|
-
new JSONFileLog({ storagePath: "./storage/logs-json", chunk: "daily" }),
|
|
153
|
-
],
|
|
154
|
-
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
155
|
-
});
|
|
156
|
-
} else if (process.env.NODE_ENV === "test") {
|
|
157
|
-
log.setChannels([]); // silence logger during tests
|
|
158
|
-
} else {
|
|
159
|
-
log.setChannels([new ConsoleLog()]);
|
|
160
|
-
}
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Import it once at the top of `src/index.ts`:
|
|
164
|
-
|
|
165
|
-
```ts title="src/index.ts"
|
|
166
|
-
import "./logger"; // side-effect: configures singleton
|
|
167
|
-
import { log } from "@warlock.js/logger";
|
|
168
|
-
|
|
169
|
-
log.info("app", "start", "Server listening on :3000");
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
## What `configure({ autoFlushOn })` does
|
|
173
|
-
|
|
174
|
-
Registers one process-level handler per event that calls `log.flushSync()` before Node exits. See [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) for the full behavior table.
|
|
175
|
-
|
|
176
|
-
```ts
|
|
177
|
-
log.configure({
|
|
178
|
-
channels: [new FileLog()],
|
|
179
|
-
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
180
|
-
});
|
|
181
|
-
// Now a buffered FileLog flushes on Ctrl+C, container stop, and natural exit.
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
Calling `configure({ autoFlushOn })` a second time **replaces** previous handlers (not stacks them). Call `log.disableAutoFlush()` to tear them down.
|
|
185
|
-
|
|
186
|
-
## Creating an isolated Logger
|
|
187
|
-
|
|
188
|
-
Rarely needed. Useful when a library wants its own channel list that doesn't share with the host app:
|
|
189
|
-
|
|
190
|
-
```ts
|
|
191
|
-
import { Logger, ConsoleLog } from "@warlock.js/logger";
|
|
192
|
-
|
|
193
|
-
export const libraryLogger = new Logger();
|
|
194
|
-
libraryLogger.addChannel(new ConsoleLog({ filter: (d) => d.module === "my-lib" }));
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
Every `new Logger()` gets a unique `id` (string, prefixed `"logger-"`).
|
|
198
|
-
|
|
199
|
-
## Order matters — ANSI stripping across channels
|
|
200
|
-
|
|
201
|
-
`Logger.log` shallow-clones the entry per non-terminal channel before stripping ANSI codes. Registering a terminal channel (ConsoleLog) **after** a non-terminal one (FileLog) still works — ConsoleLog sees the original colored message. But if you register them in reverse and add a channel that mutates `data` in place, the non-terminal channel will see the terminal channel's version. Prefer the built-ins; custom channels should not mutate `data`.
|
|
202
|
-
|
|
203
|
-
## When to call what
|
|
204
|
-
|
|
205
|
-
- **`addChannel`** — most common. Add channels as you discover you need them during setup.
|
|
206
|
-
- **`setChannels`** — when env branching makes the full list clear at once (production vs dev).
|
|
207
|
-
- **`configure`** — when you also want to install auto-flush, redact, or minLevel in the same call.
|
|
208
|
-
|
|
209
|
-
## Combining everything
|
|
210
|
-
|
|
211
|
-
```ts
|
|
212
|
-
log.configure({
|
|
213
|
-
channels: [
|
|
214
|
-
new ConsoleLog({ showContext: true }),
|
|
215
|
-
new FileLog({ chunk: "daily" }),
|
|
216
|
-
],
|
|
217
|
-
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
218
|
-
redact: { paths: ["context.password", "context.headers.authorization"] },
|
|
219
|
-
minLevel: process.env.LOG_LEVEL === "debug" ? "debug" : "info",
|
|
220
|
-
});
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
See [`@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md) for the redact contract and [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md) for `minLevel`.
|
|
224
|
-
|
|
225
|
-
## See also
|
|
226
|
-
|
|
227
|
-
- [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md) — what each built-in channel does
|
|
228
|
-
- [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) — `autoFlushOn` event behavior
|
|
124
|
+
---
|
|
125
|
+
name: configure-logger
|
|
126
|
+
description: 'Register channels via log.addChannel / log.setChannels / log.configure({channels, autoFlushOn, redact, minLevel}) at boot. Triggers: `log.configure`, `log.addChannel`, `log.setChannels`, `Logger`, `autoFlushOn`, `disableAutoFlush`; "wire channels at startup", "branch logger by NODE_ENV", "isolate a library''s logger", "replace channel list"; typical import `import { log, Logger, ConsoleLog, FileLog } from "@warlock.js/logger"`. Skip: channel picks — `@warlock.js/logger/pick-log-channel/SKILL.md`; flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; redaction — `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`; competing libs `winston.createLogger`, `pino`.'
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
# Setup — registering channels at startup
|
|
130
|
+
|
|
131
|
+
The logger is a singleton. Do all setup in one place, as early in the app entry point as possible.
|
|
132
|
+
|
|
133
|
+
## The three channel-registration methods
|
|
134
|
+
|
|
135
|
+
| Method | Semantics |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `log.addChannel(channel)` | **Appends.** Safe to call multiple times. |
|
|
138
|
+
| `log.setChannels([...])` | **Replaces** the full list. |
|
|
139
|
+
| `log.configure({ channels, autoFlushOn, redact, minLevel })` | **Replaces** channels if provided; installs auto-flush if provided; sets redact / minLevel if provided. All four are optional. |
|
|
140
|
+
|
|
141
|
+
All three return `this` — chainable.
|
|
142
|
+
|
|
143
|
+
## Recommended pattern — one dedicated file
|
|
144
|
+
|
|
145
|
+
```ts title="src/logger.ts"
|
|
146
|
+
import { log, ConsoleLog, FileLog, JSONFileLog } from "@warlock.js/logger";
|
|
147
|
+
|
|
148
|
+
if (process.env.NODE_ENV === "production") {
|
|
149
|
+
log.configure({
|
|
150
|
+
channels: [
|
|
151
|
+
new FileLog({ storagePath: "./storage/logs", chunk: "daily", rotate: true }),
|
|
152
|
+
new JSONFileLog({ storagePath: "./storage/logs-json", chunk: "daily" }),
|
|
153
|
+
],
|
|
154
|
+
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
155
|
+
});
|
|
156
|
+
} else if (process.env.NODE_ENV === "test") {
|
|
157
|
+
log.setChannels([]); // silence logger during tests
|
|
158
|
+
} else {
|
|
159
|
+
log.setChannels([new ConsoleLog()]);
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Import it once at the top of `src/index.ts`:
|
|
164
|
+
|
|
165
|
+
```ts title="src/index.ts"
|
|
166
|
+
import "./logger"; // side-effect: configures singleton
|
|
167
|
+
import { log } from "@warlock.js/logger";
|
|
168
|
+
|
|
169
|
+
log.info("app", "start", "Server listening on :3000");
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## What `configure({ autoFlushOn })` does
|
|
173
|
+
|
|
174
|
+
Registers one process-level handler per event that calls `log.flushSync()` before Node exits. See [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) for the full behavior table.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
log.configure({
|
|
178
|
+
channels: [new FileLog()],
|
|
179
|
+
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
180
|
+
});
|
|
181
|
+
// Now a buffered FileLog flushes on Ctrl+C, container stop, and natural exit.
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Calling `configure({ autoFlushOn })` a second time **replaces** previous handlers (not stacks them). Call `log.disableAutoFlush()` to tear them down.
|
|
185
|
+
|
|
186
|
+
## Creating an isolated Logger
|
|
187
|
+
|
|
188
|
+
Rarely needed. Useful when a library wants its own channel list that doesn't share with the host app:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { Logger, ConsoleLog } from "@warlock.js/logger";
|
|
192
|
+
|
|
193
|
+
export const libraryLogger = new Logger();
|
|
194
|
+
libraryLogger.addChannel(new ConsoleLog({ filter: (d) => d.module === "my-lib" }));
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Every `new Logger()` gets a unique `id` (string, prefixed `"logger-"`).
|
|
198
|
+
|
|
199
|
+
## Order matters — ANSI stripping across channels
|
|
200
|
+
|
|
201
|
+
`Logger.log` shallow-clones the entry per non-terminal channel before stripping ANSI codes. Registering a terminal channel (ConsoleLog) **after** a non-terminal one (FileLog) still works — ConsoleLog sees the original colored message. But if you register them in reverse and add a channel that mutates `data` in place, the non-terminal channel will see the terminal channel's version. Prefer the built-ins; custom channels should not mutate `data`.
|
|
202
|
+
|
|
203
|
+
## When to call what
|
|
204
|
+
|
|
205
|
+
- **`addChannel`** — most common. Add channels as you discover you need them during setup.
|
|
206
|
+
- **`setChannels`** — when env branching makes the full list clear at once (production vs dev).
|
|
207
|
+
- **`configure`** — when you also want to install auto-flush, redact, or minLevel in the same call.
|
|
208
|
+
|
|
209
|
+
## Combining everything
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
log.configure({
|
|
213
|
+
channels: [
|
|
214
|
+
new ConsoleLog({ showContext: true }),
|
|
215
|
+
new FileLog({ chunk: "daily" }),
|
|
216
|
+
],
|
|
217
|
+
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
218
|
+
redact: { paths: ["context.password", "context.headers.authorization"] },
|
|
219
|
+
minLevel: process.env.LOG_LEVEL === "debug" ? "debug" : "info",
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
See [`@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md) for the redact contract and [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md) for `minLevel`.
|
|
224
|
+
|
|
225
|
+
## See also
|
|
226
|
+
|
|
227
|
+
- [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md) — what each built-in channel does
|
|
228
|
+
- [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) — `autoFlushOn` event behavior
|
|
229
229
|
|
|
230
230
|
|
|
231
231
|
## filter-log-entries `@warlock.js/logger/filter-log-entries/SKILL.md`
|
|
232
232
|
|
|
233
|
-
---
|
|
234
|
-
name: filter-log-entries
|
|
235
|
-
description: 'Drop log entries — per-channel levels whitelist, per-channel filter predicate, logger-wide setMinLevel(level) fast path. Triggers: `levels`, `filter`, `minLevel`, `log.setMinLevel`, `shouldBeLogged`, `LoggingData`, `LogLevel`; "silence a noisy module", "route errors to a dedicated file", "raise global severity floor", "drop debug logs in prod"; typical import `import { log } from "@warlock.js/logger"`. Skip: custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; channel picks — `@warlock.js/logger/pick-log-channel/SKILL.md`; competing libs `pino.levels`, `winston.format.filter`, `debug` env var.'
|
|
236
|
-
---
|
|
237
|
-
|
|
238
|
-
# Filtering — `levels` + `filter` predicate + `minLevel`
|
|
239
|
-
|
|
240
|
-
Every channel can silently drop entries it doesn't care about. Three mechanisms stack: a logger-wide `minLevel` floor (cheapest), then per-channel `levels` whitelist, then per-channel `filter` predicate.
|
|
241
|
-
|
|
242
|
-
## 1. `levels` — the per-channel whitelist
|
|
243
|
-
|
|
244
|
-
```ts
|
|
245
|
-
new FileLog({ levels: ["error", "warn"] });
|
|
246
|
-
// debug/info/success entries → skipped
|
|
247
|
-
// error/warn entries → written
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
- Omitting `levels` (or passing `[]`) means **allow all six**.
|
|
251
|
-
- No regex / no range — it's a literal whitelist of `LogLevel` strings.
|
|
252
|
-
|
|
253
|
-
## 2. `filter` — the per-channel custom predicate
|
|
254
|
-
|
|
255
|
-
```ts
|
|
256
|
-
new ConsoleLog({
|
|
257
|
-
filter: (data) => data.module !== "healthcheck",
|
|
258
|
-
});
|
|
259
|
-
// Every entry is passed to the predicate; return false → skip.
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
- `data` is the full `LoggingData`: `{ type, module, action, message, context? }`.
|
|
263
|
-
- Predicate runs **after** `levels` — an entry blocked by `levels` never reaches `filter`.
|
|
264
|
-
|
|
265
|
-
## 3. `minLevel` — the logger-wide severity floor
|
|
266
|
-
|
|
267
|
-
For the common "drop everything below X" case, skip the per-channel `levels` array and use the logger-wide fast path:
|
|
268
|
-
|
|
269
|
-
```ts
|
|
270
|
-
log.setMinLevel("info");
|
|
271
|
-
// debug entries are dropped before fan-out — no channel ever sees them.
|
|
272
|
-
|
|
273
|
-
log.configure({ minLevel: "warn" }); // shorthand inside configure()
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
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).
|
|
277
|
-
|
|
278
|
-
Pass `undefined` to clear:
|
|
279
|
-
|
|
280
|
-
```ts
|
|
281
|
-
log.setMinLevel(undefined); // accept everything again
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
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.
|
|
285
|
-
|
|
286
|
-
## Combining — real patterns
|
|
287
|
-
|
|
288
|
-
### Route errors to a dedicated file
|
|
289
|
-
|
|
290
|
-
```ts
|
|
291
|
-
log.setChannels([
|
|
292
|
-
new ConsoleLog(),
|
|
293
|
-
new FileLog({
|
|
294
|
-
name: "errors",
|
|
295
|
-
levels: ["error", "warn"],
|
|
296
|
-
chunk: "daily",
|
|
297
|
-
}),
|
|
298
|
-
]);
|
|
299
|
-
// ConsoleLog sees everything; errors.log only grows with warnings and errors.
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
### Silence a noisy module
|
|
303
|
-
|
|
304
|
-
```ts
|
|
305
|
-
new ConsoleLog({
|
|
306
|
-
filter: (data) => data.module !== "socket.io",
|
|
307
|
-
});
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
### Keep the dev terminal focused
|
|
311
|
-
|
|
312
|
-
```ts
|
|
313
|
-
// Only surface the subsystem you're actively working on
|
|
314
|
-
new ConsoleLog({
|
|
315
|
-
filter: (data) => data.module === "auth",
|
|
316
|
-
});
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
### Errors always pass, info only for one module
|
|
320
|
-
|
|
321
|
-
```ts
|
|
322
|
-
new ConsoleLog({
|
|
323
|
-
filter: (data) => data.type === "error" || data.module === "payments",
|
|
324
|
-
});
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
## Where filtering happens
|
|
328
|
-
|
|
329
|
-
`LogChannel.shouldBeLogged(data)` runs both checks in order:
|
|
330
|
-
|
|
331
|
-
```ts
|
|
332
|
-
// levels check — fast path
|
|
333
|
-
if (this.config("levels")?.length && !this.config("levels").includes(data.type)) return false;
|
|
334
|
-
|
|
335
|
-
// filter predicate — only runs if levels allowed it
|
|
336
|
-
const filter = this.config("filter");
|
|
337
|
-
if (filter) return filter(data);
|
|
338
|
-
|
|
339
|
-
return true;
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
If you extend `LogChannel` to write a custom channel, call `this.shouldBeLogged(data)` first thing inside your `log(data)` method — you inherit both mechanisms for free. See [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md).
|
|
343
|
-
|
|
344
|
-
## Logger-wide custom filtering — not a thing
|
|
345
|
-
|
|
346
|
-
There is no `logger.setGlobalFilter()`. Each channel filters itself. If you want the same predicate everywhere, pass it to every channel constructor (or wrap your channels in a helper).
|
|
347
|
-
|
|
348
|
-
## Performance note
|
|
349
|
-
|
|
350
|
-
Filters run on **every** entry per channel. A synchronous, cheap predicate is fine. Avoid `await` inside — the channel receives a fully-formed `LoggingData` and the filter is sync-only (type: `(data: LoggingData) => boolean`).
|
|
351
|
-
|
|
352
|
-
The `minLevel` check is the fastest of the three (single comparison before fan-out), so prefer it when "drop everything below X uniformly" matches your need.
|
|
233
|
+
---
|
|
234
|
+
name: filter-log-entries
|
|
235
|
+
description: 'Drop log entries — per-channel levels whitelist, per-channel filter predicate, logger-wide setMinLevel(level) fast path. Triggers: `levels`, `filter`, `minLevel`, `log.setMinLevel`, `shouldBeLogged`, `LoggingData`, `LogLevel`; "silence a noisy module", "route errors to a dedicated file", "raise global severity floor", "drop debug logs in prod"; typical import `import { log } from "@warlock.js/logger"`. Skip: custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; channel picks — `@warlock.js/logger/pick-log-channel/SKILL.md`; competing libs `pino.levels`, `winston.format.filter`, `debug` env var.'
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
# Filtering — `levels` + `filter` predicate + `minLevel`
|
|
239
|
+
|
|
240
|
+
Every channel can silently drop entries it doesn't care about. Three mechanisms stack: a logger-wide `minLevel` floor (cheapest), then per-channel `levels` whitelist, then per-channel `filter` predicate.
|
|
241
|
+
|
|
242
|
+
## 1. `levels` — the per-channel whitelist
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
new FileLog({ levels: ["error", "warn"] });
|
|
246
|
+
// debug/info/success entries → skipped
|
|
247
|
+
// error/warn entries → written
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- Omitting `levels` (or passing `[]`) means **allow all six**.
|
|
251
|
+
- No regex / no range — it's a literal whitelist of `LogLevel` strings.
|
|
252
|
+
|
|
253
|
+
## 2. `filter` — the per-channel custom predicate
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
new ConsoleLog({
|
|
257
|
+
filter: (data) => data.module !== "healthcheck",
|
|
258
|
+
});
|
|
259
|
+
// Every entry is passed to the predicate; return false → skip.
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
- `data` is the full `LoggingData`: `{ type, module, action, message, context? }`.
|
|
263
|
+
- Predicate runs **after** `levels` — an entry blocked by `levels` never reaches `filter`.
|
|
264
|
+
|
|
265
|
+
## 3. `minLevel` — the logger-wide severity floor
|
|
266
|
+
|
|
267
|
+
For the common "drop everything below X" case, skip the per-channel `levels` array and use the logger-wide fast path:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
log.setMinLevel("info");
|
|
271
|
+
// debug entries are dropped before fan-out — no channel ever sees them.
|
|
272
|
+
|
|
273
|
+
log.configure({ minLevel: "warn" }); // shorthand inside configure()
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
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).
|
|
277
|
+
|
|
278
|
+
Pass `undefined` to clear:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
log.setMinLevel(undefined); // accept everything again
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
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.
|
|
285
|
+
|
|
286
|
+
## Combining — real patterns
|
|
287
|
+
|
|
288
|
+
### Route errors to a dedicated file
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
log.setChannels([
|
|
292
|
+
new ConsoleLog(),
|
|
293
|
+
new FileLog({
|
|
294
|
+
name: "errors",
|
|
295
|
+
levels: ["error", "warn"],
|
|
296
|
+
chunk: "daily",
|
|
297
|
+
}),
|
|
298
|
+
]);
|
|
299
|
+
// ConsoleLog sees everything; errors.log only grows with warnings and errors.
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Silence a noisy module
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
new ConsoleLog({
|
|
306
|
+
filter: (data) => data.module !== "socket.io",
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Keep the dev terminal focused
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
// Only surface the subsystem you're actively working on
|
|
314
|
+
new ConsoleLog({
|
|
315
|
+
filter: (data) => data.module === "auth",
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Errors always pass, info only for one module
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
new ConsoleLog({
|
|
323
|
+
filter: (data) => data.type === "error" || data.module === "payments",
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Where filtering happens
|
|
328
|
+
|
|
329
|
+
`LogChannel.shouldBeLogged(data)` runs both checks in order:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
// levels check — fast path
|
|
333
|
+
if (this.config("levels")?.length && !this.config("levels").includes(data.type)) return false;
|
|
334
|
+
|
|
335
|
+
// filter predicate — only runs if levels allowed it
|
|
336
|
+
const filter = this.config("filter");
|
|
337
|
+
if (filter) return filter(data);
|
|
338
|
+
|
|
339
|
+
return true;
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
If you extend `LogChannel` to write a custom channel, call `this.shouldBeLogged(data)` first thing inside your `log(data)` method — you inherit both mechanisms for free. See [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md).
|
|
343
|
+
|
|
344
|
+
## Logger-wide custom filtering — not a thing
|
|
345
|
+
|
|
346
|
+
There is no `logger.setGlobalFilter()`. Each channel filters itself. If you want the same predicate everywhere, pass it to every channel constructor (or wrap your channels in a helper).
|
|
347
|
+
|
|
348
|
+
## Performance note
|
|
349
|
+
|
|
350
|
+
Filters run on **every** entry per channel. A synchronous, cheap predicate is fine. Avoid `await` inside — the channel receives a fully-formed `LoggingData` and the filter is sync-only (type: `(data: LoggingData) => boolean`).
|
|
351
|
+
|
|
352
|
+
The `minLevel` check is the fastest of the three (single comparison before fan-out), so prefer it when "drop everything below X uniformly" matches your need.
|
|
353
353
|
|
|
354
354
|
|
|
355
355
|
## flush-logs-on-shutdown `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`
|
|
356
356
|
|
|
357
|
-
---
|
|
358
|
-
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 — `@warlock.js/logger/capture-unhandled-errors/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing `pino.final`, `winston.end`; native `process.on(''exit'')`.'
|
|
360
|
-
---
|
|
361
|
-
|
|
362
|
-
# Lifecycle — flushing buffered channels before exit
|
|
363
|
-
|
|
364
|
-
`FileLog` and `JSONFileLog` buffer entries in memory. A process that exits without draining loses the buffer.
|
|
365
|
-
|
|
366
|
-
## The easy way — `autoFlushOn`
|
|
367
|
-
|
|
368
|
-
Tell the logger which process events should trigger a flush. It installs the handlers for you.
|
|
369
|
-
|
|
370
|
-
```ts
|
|
371
|
-
log.configure({
|
|
372
|
-
channels: [new ConsoleLog(), new FileLog({ chunk: "daily" })],
|
|
373
|
-
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
374
|
-
});
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
### What each event does
|
|
378
|
-
|
|
379
|
-
| Event | Behavior |
|
|
380
|
-
|---|---|
|
|
381
|
-
| `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` | Flush in place. Node continues its natural exit. |
|
|
383
|
-
|
|
384
|
-
### Default recommendation
|
|
385
|
-
|
|
386
|
-
`["SIGINT", "SIGTERM", "beforeExit"]` covers:
|
|
387
|
-
- Local `Ctrl+C` (SIGINT)
|
|
388
|
-
- Container orchestrators (`docker stop`, Kubernetes sending SIGTERM)
|
|
389
|
-
- Natural exit (Node finished all work)
|
|
390
|
-
|
|
391
|
-
Add `"SIGHUP"` if you care about terminal disconnects. Add `"SIGUSR2"` if you use nodemon or pm2 restart.
|
|
392
|
-
|
|
393
|
-
### Idempotency
|
|
394
|
-
|
|
395
|
-
Calling `enableAutoFlush` twice **replaces** previous handlers — it does not stack. `disableAutoFlush()` removes every handler this logger instance registered; safe to call when nothing is registered.
|
|
396
|
-
|
|
397
|
-
## The manual way — your own handler
|
|
398
|
-
|
|
399
|
-
Use this when you need async work (close an HTTP server, drain a queue) **before** flushing:
|
|
400
|
-
|
|
401
|
-
```ts
|
|
402
|
-
async function gracefulShutdown() {
|
|
403
|
-
await httpServer.close();
|
|
404
|
-
await queue.drain();
|
|
405
|
-
log.flushSync(); // still sync — guarantees disk write before exit
|
|
406
|
-
process.exit(0);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
process.once("SIGINT", gracefulShutdown);
|
|
410
|
-
process.once("SIGTERM", gracefulShutdown);
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
**If you go manual for a signal, skip it in `autoFlushOn`** — otherwise both handlers fire and ours re-raises the signal mid-way through your async work.
|
|
414
|
-
|
|
415
|
-
## Async drain — `log.flush()`
|
|
416
|
-
|
|
417
|
-
`flushSync()` blocks the event loop with synchronous I/O. That's correct for the file channels and required inside the handlers `autoFlushOn` installs (a re-raised signal kills the process before any promise could settle). But a channel whose delivery is **async** — a network transport, an async disk write — can't drain synchronously. For those, `await` the async sibling on a graceful path you control:
|
|
418
|
-
|
|
419
|
-
```ts
|
|
420
|
-
async function gracefulShutdown() {
|
|
421
|
-
await httpServer.close();
|
|
422
|
-
await log.flush(); // awaits every channel's async flush() to completion
|
|
423
|
-
process.exit(0);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
process.once("SIGTERM", gracefulShutdown);
|
|
427
|
-
```
|
|
428
|
-
|
|
429
|
-
`log.flush()` fans out to every channel that implements `flush()` and awaits them together (`Promise.allSettled`). Each channel is isolated — one channel's flush rejecting neither aborts the others nor escapes as an unhandled rejection. Channels without `flush()` are skipped.
|
|
430
|
-
|
|
431
|
-
| | `flushSync()` | `flush()` |
|
|
432
|
-
|---|---|---|
|
|
433
|
-
| I/O | synchronous — blocks the loop | asynchronous — awaited |
|
|
434
|
-
| Safe in a re-raising signal handler | yes | no — the signal exits before the promise settles |
|
|
435
|
-
| Used by `autoFlushOn` | yes | no |
|
|
436
|
-
| Reach for it when | file channels, last-resort durability | network/async channels, manual `await` before `process.exit` |
|
|
437
|
-
| `FileLog` / `JSONFileLog` | ✓ | ✓ (async write) |
|
|
438
|
-
|
|
439
|
-
`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
|
-
|
|
441
|
-
## What `flushSync()` actually does
|
|
442
|
-
|
|
443
|
-
```ts
|
|
444
|
-
log.flushSync();
|
|
445
|
-
// For every registered channel:
|
|
446
|
-
// if (channel.flushSync) channel.flushSync();
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
- Synchronous I/O — blocks the event loop.
|
|
450
|
-
- Channels without `flushSync` (e.g. `ConsoleLog` — nothing to flush) are skipped silently.
|
|
451
|
-
- Works with and without `groupBy` on `FileLog` / `JSONFileLog`.
|
|
452
|
-
- No-op if every channel's buffer is empty.
|
|
453
|
-
|
|
454
|
-
`ConsoleLog` has no `flushSync` — it writes synchronously on every entry. `FileLog` and `JSONFileLog` both implement it.
|
|
455
|
-
|
|
456
|
-
## Unhandled errors
|
|
457
|
-
|
|
458
|
-
The `uncaughtException` path in [`captureAnyUnhandledRejection()`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) 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
|
-
|
|
460
|
-
```ts
|
|
461
|
-
log.configure({
|
|
462
|
-
channels: [new FileLog({ levels: ["error"] })],
|
|
463
|
-
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
captureAnyUnhandledRejection();
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
## What NOT to do
|
|
470
|
-
|
|
471
|
-
- **Don't `await` inside a signal handler you wrote yourself and then call `flushSync`** — if an async step rejects, you skip the flush. Wrap in `try { await x } finally { log.flushSync(); process.exit(1); }`.
|
|
472
|
-
- **Don't call `process.exit()` inside `autoFlushOn` handlers** — signal handlers here already re-raise the signal. Forcing an exit breaks exit codes.
|
|
473
|
-
- **Don't rely on the 5-second flush interval for shutdown safety.** It's a throughput optimization, not a durability guarantee.
|
|
357
|
+
---
|
|
358
|
+
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 — `@warlock.js/logger/capture-unhandled-errors/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing `pino.final`, `winston.end`; native `process.on(''exit'')`.'
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
# Lifecycle — flushing buffered channels before exit
|
|
363
|
+
|
|
364
|
+
`FileLog` and `JSONFileLog` buffer entries in memory. A process that exits without draining loses the buffer.
|
|
365
|
+
|
|
366
|
+
## The easy way — `autoFlushOn`
|
|
367
|
+
|
|
368
|
+
Tell the logger which process events should trigger a flush. It installs the handlers for you.
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
log.configure({
|
|
372
|
+
channels: [new ConsoleLog(), new FileLog({ chunk: "daily" })],
|
|
373
|
+
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
374
|
+
});
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### What each event does
|
|
378
|
+
|
|
379
|
+
| Event | Behavior |
|
|
380
|
+
|---|---|
|
|
381
|
+
| `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` | Flush in place. Node continues its natural exit. |
|
|
383
|
+
|
|
384
|
+
### Default recommendation
|
|
385
|
+
|
|
386
|
+
`["SIGINT", "SIGTERM", "beforeExit"]` covers:
|
|
387
|
+
- Local `Ctrl+C` (SIGINT)
|
|
388
|
+
- Container orchestrators (`docker stop`, Kubernetes sending SIGTERM)
|
|
389
|
+
- Natural exit (Node finished all work)
|
|
390
|
+
|
|
391
|
+
Add `"SIGHUP"` if you care about terminal disconnects. Add `"SIGUSR2"` if you use nodemon or pm2 restart.
|
|
392
|
+
|
|
393
|
+
### Idempotency
|
|
394
|
+
|
|
395
|
+
Calling `enableAutoFlush` twice **replaces** previous handlers — it does not stack. `disableAutoFlush()` removes every handler this logger instance registered; safe to call when nothing is registered.
|
|
396
|
+
|
|
397
|
+
## The manual way — your own handler
|
|
398
|
+
|
|
399
|
+
Use this when you need async work (close an HTTP server, drain a queue) **before** flushing:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
async function gracefulShutdown() {
|
|
403
|
+
await httpServer.close();
|
|
404
|
+
await queue.drain();
|
|
405
|
+
log.flushSync(); // still sync — guarantees disk write before exit
|
|
406
|
+
process.exit(0);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
process.once("SIGINT", gracefulShutdown);
|
|
410
|
+
process.once("SIGTERM", gracefulShutdown);
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**If you go manual for a signal, skip it in `autoFlushOn`** — otherwise both handlers fire and ours re-raises the signal mid-way through your async work.
|
|
414
|
+
|
|
415
|
+
## Async drain — `log.flush()`
|
|
416
|
+
|
|
417
|
+
`flushSync()` blocks the event loop with synchronous I/O. That's correct for the file channels and required inside the handlers `autoFlushOn` installs (a re-raised signal kills the process before any promise could settle). But a channel whose delivery is **async** — a network transport, an async disk write — can't drain synchronously. For those, `await` the async sibling on a graceful path you control:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
async function gracefulShutdown() {
|
|
421
|
+
await httpServer.close();
|
|
422
|
+
await log.flush(); // awaits every channel's async flush() to completion
|
|
423
|
+
process.exit(0);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
process.once("SIGTERM", gracefulShutdown);
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
`log.flush()` fans out to every channel that implements `flush()` and awaits them together (`Promise.allSettled`). Each channel is isolated — one channel's flush rejecting neither aborts the others nor escapes as an unhandled rejection. Channels without `flush()` are skipped.
|
|
430
|
+
|
|
431
|
+
| | `flushSync()` | `flush()` |
|
|
432
|
+
|---|---|---|
|
|
433
|
+
| I/O | synchronous — blocks the loop | asynchronous — awaited |
|
|
434
|
+
| Safe in a re-raising signal handler | yes | no — the signal exits before the promise settles |
|
|
435
|
+
| Used by `autoFlushOn` | yes | no |
|
|
436
|
+
| Reach for it when | file channels, last-resort durability | network/async channels, manual `await` before `process.exit` |
|
|
437
|
+
| `FileLog` / `JSONFileLog` | ✓ | ✓ (async write) |
|
|
438
|
+
|
|
439
|
+
`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
|
+
|
|
441
|
+
## What `flushSync()` actually does
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
log.flushSync();
|
|
445
|
+
// For every registered channel:
|
|
446
|
+
// if (channel.flushSync) channel.flushSync();
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
- Synchronous I/O — blocks the event loop.
|
|
450
|
+
- Channels without `flushSync` (e.g. `ConsoleLog` — nothing to flush) are skipped silently.
|
|
451
|
+
- Works with and without `groupBy` on `FileLog` / `JSONFileLog`.
|
|
452
|
+
- No-op if every channel's buffer is empty.
|
|
453
|
+
|
|
454
|
+
`ConsoleLog` has no `flushSync` — it writes synchronously on every entry. `FileLog` and `JSONFileLog` both implement it.
|
|
455
|
+
|
|
456
|
+
## Unhandled errors
|
|
457
|
+
|
|
458
|
+
The `uncaughtException` path in [`captureAnyUnhandledRejection()`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) 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
|
+
|
|
460
|
+
```ts
|
|
461
|
+
log.configure({
|
|
462
|
+
channels: [new FileLog({ levels: ["error"] })],
|
|
463
|
+
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
captureAnyUnhandledRejection();
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
## What NOT to do
|
|
470
|
+
|
|
471
|
+
- **Don't `await` inside a signal handler you wrote yourself and then call `flushSync`** — if an async step rejects, you skip the flush. Wrap in `try { await x } finally { log.flushSync(); process.exit(1); }`.
|
|
472
|
+
- **Don't call `process.exit()` inside `autoFlushOn` handlers** — signal handlers here already re-raise the signal. Forcing an exit breaks exit codes.
|
|
473
|
+
- **Don't rely on the 5-second flush interval for shutdown safety.** It's a throughput optimization, not a durability guarantee.
|
|
474
474
|
|
|
475
475
|
|
|
476
476
|
## logger-basics `@warlock.js/logger/logger-basics/SKILL.md`
|
|
@@ -567,985 +567,985 @@ Every call signature is the same — `module`, `action`, `message`, optional `co
|
|
|
567
567
|
|
|
568
568
|
## overview `@warlock.js/logger/overview/SKILL.md`
|
|
569
569
|
|
|
570
|
-
---
|
|
571
|
-
name: overview
|
|
572
|
-
description: "@warlock.js/logger overview for orientation and choosing the focused skill for the task."
|
|
573
|
-
---
|
|
574
|
-
|
|
575
|
-
# `@warlock.js/logger` — overview
|
|
576
|
-
|
|
577
|
-
Structured logging for Node. Six severity levels (with `fatal` strictly above `error`), a singleton plus a `Logger` class, channel-based fan-out (one entry → many sinks), PII redaction as a floor that channels can extend, buffered file writes with signal-triggered flush on shutdown, an awaitable async `log.flush()` for network/async channels, an optional Sentry channel, and a couple of ergonomic helpers (`timer`, `assert`) that turn boilerplate into one-liners.
|
|
578
|
-
|
|
579
|
-
Ships standalone — `@warlock.js/core` is not required. Drop it into any Node project.
|
|
580
|
-
|
|
581
|
-
## Server-only package
|
|
582
|
-
|
|
570
|
+
---
|
|
571
|
+
name: overview
|
|
572
|
+
description: "@warlock.js/logger overview for orientation and choosing the focused skill for the task."
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
# `@warlock.js/logger` — overview
|
|
576
|
+
|
|
577
|
+
Structured logging for Node. Six severity levels (with `fatal` strictly above `error`), a singleton plus a `Logger` class, channel-based fan-out (one entry → many sinks), PII redaction as a floor that channels can extend, buffered file writes with signal-triggered flush on shutdown, an awaitable async `log.flush()` for network/async channels, an optional Sentry channel, and a couple of ergonomic helpers (`timer`, `assert`) that turn boilerplate into one-liners.
|
|
578
|
+
|
|
579
|
+
Ships standalone — `@warlock.js/core` is not required. Drop it into any Node project.
|
|
580
|
+
|
|
581
|
+
## Server-only package
|
|
582
|
+
|
|
583
583
|
`@warlock.js/logger`'s entire runtime surface is server-only — its `package.json` declares `"warlock": { "environment": "server" }`. This is build-boundary metadata read by `@warlock.js/web`'s Gate A (import resolution) and Gate C (emitted-bundle verification), not application behavior. App code that reaches the client bundle (including page and layout modules) must not value-import `@warlock.js/logger` — Gate A refuses the build. A type-only import (`import type { ... }`) is allowed. Server loaders, controllers, and modules may import it freely.
|
|
584
|
-
|
|
585
|
-
## When to reach for it
|
|
586
|
-
|
|
587
|
-
- Building a Node service that needs **structured** logs (key-value pairs, not bare strings) and you want them to land in multiple destinations (console for dev, JSON file for prod, third-party sink for audits) without rewriting the call sites.
|
|
588
|
-
- You'd reach for **pino** or **winston** but want a smaller surface that's already wired into Warlock conventions (`module / action / message` shape, redaction floor, signal flush built-in).
|
|
589
|
-
- Your team agrees that **`console.log` doesn't survive contact with production** — you need filtering, level routing, channel-specific sinks, and a redaction story before secrets leak into Slack/Datadog.
|
|
590
|
-
|
|
591
|
-
Skip if your code is a throwaway script where `console.log` is genuinely fine — there's no value in adding a dependency for one-off logs.
|
|
592
|
-
|
|
593
|
-
## The mental model in one paragraph
|
|
594
|
-
|
|
595
|
-
You write `log.info("auth", "login", "user signed in", { userId })`. The logger fans that single entry out to every registered channel (`ConsoleLog`, `FileLog`, `JSONFileLog`, `SentryLog`, or your custom subclass). Each channel decides whether to emit it (per-level whitelist, per-channel filter predicate, logger-wide minimum severity). Redaction runs once at the logger level and can be extended per channel — never relaxed. Buffered channels (file + JSON file) drain on flush — synchronously via `log.flushSync()` / `enableAutoFlush(['SIGINT', 'SIGTERM', 'beforeExit'])`, or asynchronously via `await log.flush()` (the only path that works for network channels like `SentryLog`). That's the whole package.
|
|
596
|
-
|
|
597
|
-
## Skills index
|
|
598
|
-
|
|
599
|
-
Eleven task skills cover everything. Load the one that matches your job — most callers only ever need `logger-basics` + `configure-logger` + `pick-log-channel`.
|
|
600
|
-
|
|
601
|
-
### Foundations
|
|
602
|
-
|
|
603
|
-
#### [`logger-basics`](@warlock.js/logger/logger-basics/SKILL.md)
|
|
604
|
-
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
|
-
|
|
606
|
-
#### [`configure-logger`](@warlock.js/logger/configure-logger/SKILL.md)
|
|
607
|
-
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
|
-
|
|
609
|
-
### Channels
|
|
610
|
-
|
|
611
|
-
#### [`pick-log-channel`](@warlock.js/logger/pick-log-channel/SKILL.md)
|
|
612
|
-
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
|
-
|
|
614
|
-
#### [`ship-logs-to-sentry`](@warlock.js/logger/ship-logs-to-sentry/SKILL.md)
|
|
615
|
-
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
|
-
|
|
617
|
-
#### [`write-custom-log-channel`](@warlock.js/logger/write-custom-log-channel/SKILL.md)
|
|
618
|
-
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
|
-
|
|
620
|
-
### Production concerns
|
|
621
|
-
|
|
622
|
-
#### [`redact-sensitive-log-fields`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md)
|
|
623
|
-
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
|
-
|
|
625
|
-
#### [`filter-log-entries`](@warlock.js/logger/filter-log-entries/SKILL.md)
|
|
626
|
-
Drop entries before they cost anything. Logger-wide `setMinLevel("info")` is the fast path; per-channel `levels` array + `filter` predicate for fine control.
|
|
627
|
-
|
|
628
|
-
#### [`flush-logs-on-shutdown`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md)
|
|
629
|
-
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
|
-
|
|
631
|
-
#### [`capture-unhandled-errors`](@warlock.js/logger/capture-unhandled-errors/SKILL.md)
|
|
632
|
-
`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
|
-
|
|
634
|
-
### Ergonomics + testing
|
|
635
|
-
|
|
636
|
-
#### [`use-log-helpers`](@warlock.js/logger/use-log-helpers/SKILL.md)
|
|
637
|
-
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
|
-
|
|
639
|
-
#### [`test-logging-code`](@warlock.js/logger/test-logging-code/SKILL.md)
|
|
640
|
-
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
|
-
|
|
642
|
-
## Built-in channels at a glance
|
|
643
|
-
|
|
644
|
-
| Channel | Sink | `terminal` | Buffered |
|
|
645
|
-
| --- | --- | --- | --- |
|
|
646
|
-
| `ConsoleLog` | `process.stdout` | `true` (colors kept) | no |
|
|
647
|
-
| `FileLog` | `.log` files | `false` (ANSI stripped) | yes (5s timer or 100-entry buffer) |
|
|
648
|
-
| `JSONFileLog` | `.json` files | `false` (ANSI stripped) | yes (same buffering) |
|
|
649
|
-
| `SentryLog` | Sentry events + breadcrumbs | `false` | via the Sentry SDK's own transport (`@sentry/node` is an optional peer) |
|
|
650
|
-
|
|
651
|
-
`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
|
-
|
|
653
|
-
## What this package deliberately doesn't do
|
|
654
|
-
|
|
655
|
-
- **Distributed tracing.** Use OpenTelemetry. Logger gets you structured local logs with `module / action`; trace correlation is a different problem.
|
|
656
|
-
- **Log aggregator integrations beyond Sentry.** `SentryLog` is bundled (with `@sentry/node` as an optional peer). For Datadog, Loki, ELK, etc., either write a custom channel that POSTs to the aggregator or use `JSONFileLog` + a sidecar (fluentbit, vector, promtail).
|
|
657
|
-
- **Pretty-printing of arbitrary objects.** `ConsoleLog` has a `showContext` flag that runs `util.inspect` on the context object; for richer formatting, use `JSONFileLog` and view the file through your favorite viewer.
|
|
658
|
-
- **Log analysis.** Querying / aggregating / alerting is on the sink side (Loki, ELK, Datadog).
|
|
659
|
-
|
|
660
|
-
## See also
|
|
661
|
-
|
|
662
|
-
- [`@warlock.js/core/warlock-conventions`](@warlock.js/core/warlock-conventions/SKILL.md) — the parent framework's conventions; logger is one of its foundation packages and ships transitively when you install core.
|
|
663
|
-
- When synced via agent-kit, this `overview/SKILL.md` is flattened to the front-door skill `.claude/skills/warlock-js-logger-overview/` — every cross-link above uses the `@warlock.js/logger/<skill>/SKILL.md` name form so it survives that flattening.
|
|
584
|
+
|
|
585
|
+
## When to reach for it
|
|
586
|
+
|
|
587
|
+
- Building a Node service that needs **structured** logs (key-value pairs, not bare strings) and you want them to land in multiple destinations (console for dev, JSON file for prod, third-party sink for audits) without rewriting the call sites.
|
|
588
|
+
- You'd reach for **pino** or **winston** but want a smaller surface that's already wired into Warlock conventions (`module / action / message` shape, redaction floor, signal flush built-in).
|
|
589
|
+
- Your team agrees that **`console.log` doesn't survive contact with production** — you need filtering, level routing, channel-specific sinks, and a redaction story before secrets leak into Slack/Datadog.
|
|
590
|
+
|
|
591
|
+
Skip if your code is a throwaway script where `console.log` is genuinely fine — there's no value in adding a dependency for one-off logs.
|
|
592
|
+
|
|
593
|
+
## The mental model in one paragraph
|
|
594
|
+
|
|
595
|
+
You write `log.info("auth", "login", "user signed in", { userId })`. The logger fans that single entry out to every registered channel (`ConsoleLog`, `FileLog`, `JSONFileLog`, `SentryLog`, or your custom subclass). Each channel decides whether to emit it (per-level whitelist, per-channel filter predicate, logger-wide minimum severity). Redaction runs once at the logger level and can be extended per channel — never relaxed. Buffered channels (file + JSON file) drain on flush — synchronously via `log.flushSync()` / `enableAutoFlush(['SIGINT', 'SIGTERM', 'beforeExit'])`, or asynchronously via `await log.flush()` (the only path that works for network channels like `SentryLog`). That's the whole package.
|
|
596
|
+
|
|
597
|
+
## Skills index
|
|
598
|
+
|
|
599
|
+
Eleven task skills cover everything. Load the one that matches your job — most callers only ever need `logger-basics` + `configure-logger` + `pick-log-channel`.
|
|
600
|
+
|
|
601
|
+
### Foundations
|
|
602
|
+
|
|
603
|
+
#### [`logger-basics`](@warlock.js/logger/logger-basics/SKILL.md)
|
|
604
|
+
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
|
+
|
|
606
|
+
#### [`configure-logger`](@warlock.js/logger/configure-logger/SKILL.md)
|
|
607
|
+
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
|
+
|
|
609
|
+
### Channels
|
|
610
|
+
|
|
611
|
+
#### [`pick-log-channel`](@warlock.js/logger/pick-log-channel/SKILL.md)
|
|
612
|
+
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
|
+
|
|
614
|
+
#### [`ship-logs-to-sentry`](@warlock.js/logger/ship-logs-to-sentry/SKILL.md)
|
|
615
|
+
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
|
+
|
|
617
|
+
#### [`write-custom-log-channel`](@warlock.js/logger/write-custom-log-channel/SKILL.md)
|
|
618
|
+
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
|
+
|
|
620
|
+
### Production concerns
|
|
621
|
+
|
|
622
|
+
#### [`redact-sensitive-log-fields`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md)
|
|
623
|
+
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
|
+
|
|
625
|
+
#### [`filter-log-entries`](@warlock.js/logger/filter-log-entries/SKILL.md)
|
|
626
|
+
Drop entries before they cost anything. Logger-wide `setMinLevel("info")` is the fast path; per-channel `levels` array + `filter` predicate for fine control.
|
|
627
|
+
|
|
628
|
+
#### [`flush-logs-on-shutdown`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md)
|
|
629
|
+
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
|
+
|
|
631
|
+
#### [`capture-unhandled-errors`](@warlock.js/logger/capture-unhandled-errors/SKILL.md)
|
|
632
|
+
`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
|
+
|
|
634
|
+
### Ergonomics + testing
|
|
635
|
+
|
|
636
|
+
#### [`use-log-helpers`](@warlock.js/logger/use-log-helpers/SKILL.md)
|
|
637
|
+
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
|
+
|
|
639
|
+
#### [`test-logging-code`](@warlock.js/logger/test-logging-code/SKILL.md)
|
|
640
|
+
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
|
+
|
|
642
|
+
## Built-in channels at a glance
|
|
643
|
+
|
|
644
|
+
| Channel | Sink | `terminal` | Buffered |
|
|
645
|
+
| --- | --- | --- | --- |
|
|
646
|
+
| `ConsoleLog` | `process.stdout` | `true` (colors kept) | no |
|
|
647
|
+
| `FileLog` | `.log` files | `false` (ANSI stripped) | yes (5s timer or 100-entry buffer) |
|
|
648
|
+
| `JSONFileLog` | `.json` files | `false` (ANSI stripped) | yes (same buffering) |
|
|
649
|
+
| `SentryLog` | Sentry events + breadcrumbs | `false` | via the Sentry SDK's own transport (`@sentry/node` is an optional peer) |
|
|
650
|
+
|
|
651
|
+
`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
|
+
|
|
653
|
+
## What this package deliberately doesn't do
|
|
654
|
+
|
|
655
|
+
- **Distributed tracing.** Use OpenTelemetry. Logger gets you structured local logs with `module / action`; trace correlation is a different problem.
|
|
656
|
+
- **Log aggregator integrations beyond Sentry.** `SentryLog` is bundled (with `@sentry/node` as an optional peer). For Datadog, Loki, ELK, etc., either write a custom channel that POSTs to the aggregator or use `JSONFileLog` + a sidecar (fluentbit, vector, promtail).
|
|
657
|
+
- **Pretty-printing of arbitrary objects.** `ConsoleLog` has a `showContext` flag that runs `util.inspect` on the context object; for richer formatting, use `JSONFileLog` and view the file through your favorite viewer.
|
|
658
|
+
- **Log analysis.** Querying / aggregating / alerting is on the sink side (Loki, ELK, Datadog).
|
|
659
|
+
|
|
660
|
+
## See also
|
|
661
|
+
|
|
662
|
+
- [`@warlock.js/core/warlock-conventions`](@warlock.js/core/warlock-conventions/SKILL.md) — the parent framework's conventions; logger is one of its foundation packages and ships transitively when you install core.
|
|
663
|
+
- When synced via agent-kit, this `overview/SKILL.md` is flattened to the front-door skill `.claude/skills/warlock-js-logger-overview/` — every cross-link above uses the `@warlock.js/logger/<skill>/SKILL.md` name form so it survives that flattening.
|
|
664
664
|
|
|
665
665
|
|
|
666
666
|
## pick-log-channel `@warlock.js/logger/pick-log-channel/SKILL.md`
|
|
667
667
|
|
|
668
|
-
---
|
|
669
|
-
name: pick-log-channel
|
|
670
|
-
description: 'Pick one of the four built-in channels — ConsoleLog (terminal), FileLog (plain text on disk), JSONFileLog (structured JSON for aggregators like Loki / Datadog / Elastic), SentryLog (forwards errors + breadcrumbs to Sentry). Triggers: `ConsoleLog`, `FileLog`, `JSONFileLog`, `SentryLog`, `chunk`, `rotate`, `groupBy`, `maxFileSize`, `showContext`, `log.channel`; "log to a file", "rotate log files", "daily log chunks", "json logs for datadog / loki / elastic", "send logs to Sentry"; typical import `import { ConsoleLog, FileLog, JSONFileLog, SentryLog } from "@warlock.js/logger"`. Skip: Sentry-specific setup — `@warlock.js/logger/ship-logs-to-sentry/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; registration — `@warlock.js/logger/configure-logger/SKILL.md`; competing libs `winston-daily-rotate-file`, `pino-pretty`.'
|
|
671
|
-
---
|
|
672
|
-
|
|
673
|
-
# Channels — which one to pick and how to configure it
|
|
674
|
-
|
|
675
|
-
Four built-in channels (`SentryLog` needs the optional `@sentry/node` peer). A channel is a destination for a log entry — the logger fans out every entry to every registered channel in parallel.
|
|
676
|
-
|
|
677
|
-
## The decision
|
|
678
|
-
|
|
679
|
-
| Need | Pick |
|
|
680
|
-
|---|---|
|
|
681
|
-
| Local dev, colored output in the terminal | `ConsoleLog` |
|
|
682
|
-
| Plain text `.log` files on disk — humans read them | `FileLog` |
|
|
683
|
-
| Structured `.json` files — a log aggregator (Loki / Datadog / Elastic) reads them | `JSONFileLog` |
|
|
684
|
-
| Errors & warnings into Sentry (events + breadcrumbs) | `SentryLog` |
|
|
685
|
-
|
|
686
|
-
Most production setups use **two** channels: `ConsoleLog` + one file channel. Dev uses `ConsoleLog` only.
|
|
687
|
-
|
|
688
|
-
## `ConsoleLog`
|
|
689
|
-
|
|
690
|
-
Zero config. Colored, icon-prefixed lines to the terminal.
|
|
691
|
-
|
|
692
|
-
```ts
|
|
693
|
-
import { ConsoleLog } from "@warlock.js/logger";
|
|
694
|
-
|
|
695
|
-
new ConsoleLog();
|
|
696
|
-
// ⚙ debug (10:22:00.000) [auth] [hashPassword] Hashing started
|
|
697
|
-
// ℹ info (10:22:01.482) [users] [register] New user created
|
|
698
|
-
// ✗ error (10:22:03.111) [payments] [charge] Card declined
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
Properties:
|
|
702
|
-
- `name = "console"`, `terminal = true`
|
|
703
|
-
- Accepts `ConsoleLogConfig` — `levels`, `filter`, `dateFormat`, `showContext`, `contextDepth`
|
|
704
|
-
- If `message` is an object, a second `console.log(message)` is issued so Node's inspector can expand it
|
|
705
|
-
|
|
706
|
-
### Showing context
|
|
707
|
-
|
|
708
|
-
By default `ConsoleLog` drops the `context` payload (the file/JSON channels still keep it). Flip `showContext: true` to render it on a second line — useful in development:
|
|
709
|
-
|
|
710
|
-
```ts
|
|
711
|
-
new ConsoleLog({ showContext: true });
|
|
712
|
-
|
|
713
|
-
log.info("payments", "charge", "card declined", { userId: 42, amount: 1999 });
|
|
714
|
-
// ℹ info (…) [payments] [charge] card declined
|
|
715
|
-
// ↳ { userId: 42, amount: 1999 }
|
|
716
|
-
```
|
|
717
|
-
|
|
718
|
-
Tune `contextDepth` (default `4`) to clamp how deep `util.inspect` recurses into nested objects.
|
|
719
|
-
|
|
720
|
-
## `FileLog`
|
|
721
|
-
|
|
722
|
-
Plain text. Buffers in memory, flushes to disk periodically.
|
|
723
|
-
|
|
724
|
-
```ts
|
|
725
|
-
import { FileLog } from "@warlock.js/logger";
|
|
726
|
-
|
|
727
|
-
new FileLog({
|
|
728
|
-
storagePath: "./storage/logs", // default: process.cwd() + "/storage/logs"
|
|
729
|
-
name: "app", // default: "app"
|
|
730
|
-
extension: "log", // default: "log"
|
|
731
|
-
chunk: "daily", // "single" (default) | "daily" | "hourly"
|
|
732
|
-
rotate: true, // default: true
|
|
733
|
-
maxFileSize: 10 * 1024 * 1024, // default: 10MB — triggers rotation
|
|
734
|
-
maxMessagesToWrite: 100, // default: 100 — flush threshold
|
|
735
|
-
groupBy: ["level", "module"], // optional subdirectory nesting
|
|
736
|
-
});
|
|
737
|
-
```
|
|
738
|
-
|
|
739
|
-
Line format: `[date time] [level] [module][action]: message` — or a `[trace]` block when `message` is an `Error`.
|
|
740
|
-
|
|
741
|
-
### Key gotchas
|
|
742
|
-
|
|
743
|
-
- **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-YYYY.log`. Combined with `rotate: true`, rotated archives get `Date.now()` suffixed.
|
|
745
|
-
- **`groupBy` nests directories.** `groupBy: ["level", "module"]` produces `storage/logs/error/payments/app.log`. Order matters.
|
|
746
|
-
- **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
|
-
|
|
748
|
-
## `JSONFileLog`
|
|
749
|
-
|
|
750
|
-
Subclass of `FileLog` — same buffering, chunking, rotation, grouping. Output is a JSON object with a `messages` array:
|
|
751
|
-
|
|
752
|
-
```json
|
|
753
|
-
{
|
|
754
|
-
"messages": [
|
|
755
|
-
{
|
|
756
|
-
"content": "Card declined",
|
|
757
|
-
"level": "error",
|
|
758
|
-
"date": "15-03-2024 10:22:03",
|
|
759
|
-
"module": "payments",
|
|
760
|
-
"action": "charge",
|
|
761
|
-
"stack": [
|
|
762
|
-
"Error: Card declined",
|
|
763
|
-
" at chargeCard (/app/src/payments.ts:42:11)"
|
|
764
|
-
]
|
|
765
|
-
}
|
|
766
|
-
]
|
|
767
|
-
}
|
|
768
|
-
```
|
|
769
|
-
|
|
770
|
-
Differences from `FileLog`:
|
|
771
|
-
- `name = "fileJson"` (**not** `"json"` — use this exact string for `log.channel("fileJson")`)
|
|
772
|
-
- `extension` is always `"json"` — the option is silently ignored
|
|
773
|
-
- Error `stack` is stored as `string[]` (split on newlines) — easy to query in aggregators
|
|
774
|
-
- `content` holds the original user-supplied `message` (not a pre-formatted line)
|
|
775
|
-
- Corrupted existing file → reinitialized to `{ messages: [] }` on next write (does not throw)
|
|
776
|
-
- **Safe serialization by construction.** All writes go through `safe-stable-stringify` with a custom `Error` replacer — circular refs become `"[Circular]"`, BigInt is stringified, functions/symbols are dropped, nested `Error` instances expand to `{ name, message, stack, ...enumerable }`. A context payload with a class graph or circular reference will never throw during the write.
|
|
777
|
-
|
|
778
|
-
## `SentryLog`
|
|
779
|
-
|
|
780
|
-
Forwards entries to Sentry — `error` / `warn` become events (`captureException` for `Error` messages, `captureMessage` otherwise), every other level a breadcrumb (no quota). `@sentry/node` is an optional peer, lazily imported; pass an existing `client` or `options`.
|
|
781
|
-
|
|
782
|
-
```ts
|
|
783
|
-
import * as Sentry from "@sentry/node";
|
|
784
|
-
import { SentryLog } from "@warlock.js/logger";
|
|
785
|
-
|
|
786
|
-
new SentryLog({ client: Sentry, eventLevels: ["error", "warn"] });
|
|
787
|
-
```
|
|
788
|
-
|
|
789
|
-
Full guide — level mapping, init modes, shutdown draining: [`ship-logs-to-sentry`](@warlock.js/logger/ship-logs-to-sentry/SKILL.md).
|
|
790
|
-
|
|
791
|
-
## Shared config — `BasicLogConfigurations`
|
|
792
|
-
|
|
793
|
-
Every channel constructor accepts at minimum:
|
|
794
|
-
|
|
795
|
-
```ts
|
|
796
|
-
type BasicLogConfigurations = {
|
|
797
|
-
levels?: LogLevel[]; // whitelist — omit or [] to allow all
|
|
798
|
-
filter?: (data: LoggingData) => boolean; // custom predicate
|
|
799
|
-
dateFormat?: { date?: string; time?: string }; // Day.js format strings
|
|
800
|
-
context?: (data) => Promise<Record<string, any>>; // reserved — not yet read
|
|
801
|
-
};
|
|
802
|
-
```
|
|
803
|
-
|
|
804
|
-
Concrete file channels extend this with their storage/chunk/rotate/groupBy options via intersection.
|
|
805
|
-
|
|
806
|
-
## Picking a channel by name at runtime
|
|
807
|
-
|
|
808
|
-
```ts
|
|
809
|
-
log.channel("console"); // → ConsoleLog | undefined
|
|
810
|
-
log.channel("file"); // → FileLog | undefined
|
|
811
|
-
log.channel("fileJson"); // ← note the name — NOT "json"
|
|
812
|
-
log.channel("sentry"); // → SentryLog | undefined
|
|
813
|
-
```
|
|
814
|
-
|
|
815
|
-
If two channels share a `name`, only one is reachable this way — the search returns the first match.
|
|
816
|
-
|
|
817
|
-
## See also
|
|
818
|
-
|
|
819
|
-
- [`@warlock.js/logger/configure-logger/SKILL.md`](@warlock.js/logger/configure-logger/SKILL.md) — registering channels at startup
|
|
820
|
-
- [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md) — `levels` and `filter` config in detail
|
|
821
|
-
- [`@warlock.js/logger/ship-logs-to-sentry/SKILL.md`](@warlock.js/logger/ship-logs-to-sentry/SKILL.md) — the `SentryLog` channel in depth
|
|
822
|
-
- [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md) — extending `LogChannel` for custom sinks
|
|
668
|
+
---
|
|
669
|
+
name: pick-log-channel
|
|
670
|
+
description: 'Pick one of the four built-in channels — ConsoleLog (terminal), FileLog (plain text on disk), JSONFileLog (structured JSON for aggregators like Loki / Datadog / Elastic), SentryLog (forwards errors + breadcrumbs to Sentry). Triggers: `ConsoleLog`, `FileLog`, `JSONFileLog`, `SentryLog`, `chunk`, `rotate`, `groupBy`, `maxFileSize`, `showContext`, `log.channel`; "log to a file", "rotate log files", "daily log chunks", "json logs for datadog / loki / elastic", "send logs to Sentry"; typical import `import { ConsoleLog, FileLog, JSONFileLog, SentryLog } from "@warlock.js/logger"`. Skip: Sentry-specific setup — `@warlock.js/logger/ship-logs-to-sentry/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; registration — `@warlock.js/logger/configure-logger/SKILL.md`; competing libs `winston-daily-rotate-file`, `pino-pretty`.'
|
|
671
|
+
---
|
|
823
672
|
|
|
673
|
+
# Channels — which one to pick and how to configure it
|
|
824
674
|
|
|
825
|
-
|
|
675
|
+
Four built-in channels (`SentryLog` needs the optional `@sentry/node` peer). A channel is a destination for a log entry — the logger fans out every entry to every registered channel in parallel.
|
|
826
676
|
|
|
827
|
-
|
|
828
|
-
name: redact-sensitive-log-fields
|
|
829
|
-
description: 'Strip secrets from log output — a built-in secret-key denylist on by default (DEFAULT_REDACT_KEYS), plus two-layer additive redaction via log.configure({redact: {paths, keys}}) (logger floor) + per-channel redact (more on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `keys`, `defaultKeys`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging", "turn off default redaction"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing libs `pino.redact`, `fast-redact`.'
|
|
830
|
-
---
|
|
831
|
-
|
|
832
|
-
# Redaction — keeping secrets out of logs
|
|
833
|
-
|
|
834
|
-
Three layers: a **built-in key denylist that is on by default**, plus opt-in path globs configured at the logger and/or per channel.
|
|
835
|
-
|
|
836
|
-
## Layer 0 — the default denylist (since 4.15.0, no configuration needed)
|
|
837
|
-
|
|
838
|
-
Common secret **key names** are censored at any depth of `context`, `message`, and an `Error`'s own enumerable properties — before any of your config runs:
|
|
839
|
-
|
|
840
|
-
```ts
|
|
841
|
-
log.error("auth", "login", "failed", { headers: req.headers, body: req.body });
|
|
842
|
-
// context.headers.authorization → "[REDACTED]"
|
|
843
|
-
// context.body.password → "[REDACTED]"
|
|
844
|
-
// context.body.email → untouched
|
|
845
|
-
```
|
|
846
|
-
|
|
847
|
-
Keys are matched **exactly**, on a normalized form (lower-cased, separators stripped) — so one entry covers `apiKey` / `api_key` / `API-KEY` / `x-api-key`. It is not substring matching: `tokenCount` and `passwordUpdatedAt` survive. Read the exact set from `DEFAULT_REDACT_KEYS`.
|
|
848
|
-
|
|
849
|
-
```ts
|
|
850
|
-
import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
|
|
851
|
-
|
|
852
|
-
log.configure({
|
|
853
|
-
redact: {
|
|
854
|
-
keys: ["internalRef"], // union with the built-in set
|
|
855
|
-
defaultKeys: false, // opt out of the built-in set entirely
|
|
856
|
-
},
|
|
857
|
-
});
|
|
858
|
-
```
|
|
859
|
-
|
|
860
|
-
`defaultKeys: false` is an escape hatch, not a tuning knob — it restores the pre-4.15.0 behavior where a `password` in `context` reaches every sink in cleartext. Prefer adding a function `censor` if you only need to keep a prefix.
|
|
861
|
-
|
|
862
|
-
**A channel cannot turn the default set off** (it can only add keys, or turn it back *on* if the logger-wide config disabled it) — same additive-only contract as paths, below. And `log.setRedact(undefined)` clears *your* paths, not the default denylist.
|
|
863
|
-
|
|
864
|
-
Not reachable by any layer: secrets interpolated into a `message` string (`` `token=${t}` ``), `Map`/`Set`/`Buffer` contents, and getter-backed or non-enumerable properties.
|
|
865
|
-
|
|
866
|
-
## Layers 1 & 2 — path globs, opt-in
|
|
867
|
-
|
|
868
|
-
For anything the denylist can't name by key — a secret under an app-specific key, or a value you want partially masked rather than blanked.
|
|
869
|
-
|
|
870
|
-
The model in one line:
|
|
871
|
-
|
|
872
|
-
> Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
|
|
873
|
-
|
|
874
|
-
That guarantee is the whole point — once you've set `password` to redact at the logger, you can audit one place to know nothing leaks it, regardless of how many channels you add.
|
|
875
|
-
|
|
876
|
-
## Logger-wide floor
|
|
877
|
-
|
|
878
|
-
```ts
|
|
879
|
-
import { log } from "@warlock.js/logger";
|
|
880
|
-
|
|
881
|
-
log.configure({
|
|
882
|
-
redact: {
|
|
883
|
-
paths: [
|
|
884
|
-
"context.password",
|
|
885
|
-
"context.*.token",
|
|
886
|
-
"context.headers.authorization",
|
|
887
|
-
],
|
|
888
|
-
censor: "[REDACTED]", // default — string or function
|
|
889
|
-
},
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
// runtime equivalent:
|
|
893
|
-
log.setRedact({ paths: ["context.password"] });
|
|
894
|
-
log.setRedact(undefined); // clear your paths (the default denylist stays on)
|
|
895
|
-
```
|
|
896
|
-
|
|
897
|
-
Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
|
|
898
|
-
|
|
899
|
-
## Per-channel additive
|
|
900
|
-
|
|
901
|
-
```ts
|
|
902
|
-
new SlackChannel({
|
|
903
|
-
webhook: "...",
|
|
904
|
-
redact: {
|
|
905
|
-
paths: ["context.user.email", "context.metadata.*"],
|
|
906
|
-
// censor inherited from logger-wide when omitted
|
|
907
|
-
},
|
|
908
|
-
});
|
|
909
|
-
```
|
|
910
|
-
|
|
911
|
-
The channel's `paths` are **merged** with the logger floor — the channel runs a single combined redact pass, never replaces the floor. The channel's `censor` (if provided) wins for both its own and the logger's paths in this channel only; the logger floor still uses its own censor for other channels.
|
|
912
|
-
|
|
913
|
-
### When to set redact per-channel
|
|
914
|
-
|
|
915
|
-
- Loud destinations with broader audiences (Slack, Discord, error trackers, anything off your machine) — redact more aggressively.
|
|
916
|
-
- Local-only destinations (FileLog you alone read, the dev terminal) — keep the floor minimal so you can debug.
|
|
917
|
-
|
|
918
|
-
### When NOT to set it
|
|
919
|
-
|
|
920
|
-
If you want raw context in your dev terminal, **don't add redact at the logger level** — set it only on the file/JSON/network channels. Logger-wide is the floor, so it applies everywhere; you can't opt a single channel out.
|
|
921
|
-
|
|
922
|
-
## Path syntax
|
|
923
|
-
|
|
924
|
-
Paths are dotted glob patterns evaluated against the full `LoggingData`:
|
|
925
|
-
|
|
926
|
-
```
|
|
927
|
-
type LoggingData = {
|
|
928
|
-
type: "info" | ...,
|
|
929
|
-
module: string,
|
|
930
|
-
action: string,
|
|
931
|
-
message: any, // ← prefix paths with "message." to redact here
|
|
932
|
-
context?: object, // ← prefix paths with "context." to redact here
|
|
933
|
-
};
|
|
934
|
-
```
|
|
935
|
-
|
|
936
|
-
| Pattern | Matches |
|
|
937
|
-
| --- | --- |
|
|
938
|
-
| `context.password` | exactly `data.context.password` |
|
|
939
|
-
| `context.*.token` | `data.context.<any>.token` (one segment in between) |
|
|
940
|
-
| `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
|
|
941
|
-
| `message.apiKey` | when message is an object, `data.message.apiKey` |
|
|
942
|
-
| `context.users.*.token` | array element redaction (`*` matches indices too) |
|
|
943
|
-
|
|
944
|
-
Wildcards:
|
|
945
|
-
|
|
946
|
-
- `*` — exactly one segment (any object key, any array index).
|
|
947
|
-
- `**` — zero or more segments, greedily; matches at any depth.
|
|
948
|
-
|
|
949
|
-
## Censor variants
|
|
950
|
-
|
|
951
|
-
```ts
|
|
952
|
-
// String — replace with a literal.
|
|
953
|
-
{ censor: "[REDACTED]" }
|
|
954
|
-
{ censor: "***" }
|
|
955
|
-
|
|
956
|
-
// Function — receives original value + dotted path, returns the replacement.
|
|
957
|
-
{
|
|
958
|
-
censor: (value, path) => {
|
|
959
|
-
if (typeof value !== "string") return "[REDACTED]";
|
|
960
|
-
return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***";
|
|
961
|
-
},
|
|
962
|
-
}
|
|
963
|
-
```
|
|
964
|
-
|
|
965
|
-
Function censors are called for every match — keep them cheap. The path is the actual matched location (e.g. `"context.users.0.token"` for an array hit).
|
|
966
|
-
|
|
967
|
-
## Immutability
|
|
968
|
-
|
|
969
|
-
`applyRedact` always returns a deep clone — your input data is never mutated. `Date` and `Error` instances are reconstructed (so `instanceof` checks still work). Circular references are tolerated.
|
|
970
|
-
|
|
971
|
-
## What about the `message` field?
|
|
972
|
-
|
|
973
|
-
If `message` is a plain object, paths under `message.*` work as expected. If `message` is a string (the most common case), redaction won't scan it — string scrubbing requires regex and is out of scope for this primitive. Wrap secrets in `context` and they'll be redacted reliably.
|
|
974
|
-
|
|
975
|
-
## Performance notes
|
|
976
|
-
|
|
977
|
-
- **No redact configured** → the default denylist still runs (since 4.15.0): a cheap presence scan for a denylisted key on every `log()` call, with the deep clone + censor pass skipped entirely when nothing matches. Only `{ defaultKeys: false }` (no `paths`, no extra `keys`) gets back to zero work.
|
|
978
|
-
- **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
|
|
979
|
-
- **Channel adds paths** → that channel re-clones from the original input and runs the merged pass once. Other channels still share the cheaper logger-wide clone.
|
|
980
|
-
- Each path is matched independently; cost grows linearly with `paths.length`.
|
|
981
|
-
|
|
982
|
-
For most apps with `<10` redact paths and shallow context, the cost is below 100µs per entry. If you're logging millions of entries per second through paths like `**.something`, profile before scaling up — `**` is the only pattern that recurses through every key.
|
|
677
|
+
## The decision
|
|
983
678
|
|
|
679
|
+
| Need | Pick |
|
|
680
|
+
|---|---|
|
|
681
|
+
| Local dev, colored output in the terminal | `ConsoleLog` |
|
|
682
|
+
| Plain text `.log` files on disk — humans read them | `FileLog` |
|
|
683
|
+
| Structured `.json` files — a log aggregator (Loki / Datadog / Elastic) reads them | `JSONFileLog` |
|
|
684
|
+
| Errors & warnings into Sentry (events + breadcrumbs) | `SentryLog` |
|
|
984
685
|
|
|
985
|
-
|
|
686
|
+
Most production setups use **two** channels: `ConsoleLog` + one file channel. Dev uses `ConsoleLog` only.
|
|
986
687
|
|
|
987
|
-
|
|
988
|
-
name: ship-logs-to-sentry
|
|
989
|
-
description: 'Forward log entries to Sentry with the SentryLog channel — error/warn become events (captureException/captureMessage), every other level a breadcrumb (no quota). @sentry/node is an OPTIONAL peer, lazily imported. Triggers: `SentryLog`, `@sentry/node`, `eventLevels`, `flushTimeout`, `Sentry.flush`, `captureException`, `addBreadcrumb`, `withScope`; "send logs to Sentry", "report errors to Sentry", "Sentry log channel", "Sentry breadcrumbs from logs", "log channel for Sentry"; typical import `import { SentryLog } from "@warlock.js/logger"`. Skip: file/console channels — `@warlock.js/logger/pick-log-channel/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; graceful-shutdown flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; Slack alerting recipe.'
|
|
990
|
-
---
|
|
991
|
-
|
|
992
|
-
# Ship logs to Sentry — the `SentryLog` channel
|
|
993
|
-
|
|
994
|
-
`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:
|
|
995
|
-
|
|
996
|
-
```bash
|
|
997
|
-
npm install @sentry/node
|
|
998
|
-
```
|
|
999
|
-
|
|
1000
|
-
## Two ways to wire it
|
|
1001
|
-
|
|
1002
|
-
### Reuse an existing Sentry client (existing apps)
|
|
1003
|
-
|
|
1004
|
-
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:
|
|
1005
|
-
|
|
1006
|
-
```ts
|
|
1007
|
-
import * as Sentry from "@sentry/node";
|
|
1008
|
-
import { log, SentryLog } from "@warlock.js/logger";
|
|
1009
|
-
|
|
1010
|
-
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production" });
|
|
1011
|
-
|
|
1012
|
-
log.addChannel(new SentryLog({ client: Sentry }));
|
|
1013
|
-
```
|
|
1014
|
-
|
|
1015
|
-
### Let the channel initialize Sentry (new apps)
|
|
1016
|
-
|
|
1017
|
-
Pass `options`; the channel lazily imports `@sentry/node` and calls `Sentry.init` once — guarded so it never clobbers an existing client:
|
|
1018
|
-
|
|
1019
|
-
```ts
|
|
1020
|
-
import { log, SentryLog } from "@warlock.js/logger";
|
|
1021
|
-
|
|
1022
|
-
log.addChannel(
|
|
1023
|
-
new SentryLog({
|
|
1024
|
-
options: {
|
|
1025
|
-
dsn: process.env.SENTRY_DSN,
|
|
1026
|
-
environment: process.env.NODE_ENV,
|
|
1027
|
-
release: process.env.GIT_SHA,
|
|
1028
|
-
},
|
|
1029
|
-
}),
|
|
1030
|
-
);
|
|
1031
|
-
```
|
|
1032
|
-
|
|
1033
|
-
With neither `client` nor `options`, the channel reuses whatever global Sentry client the host already initialized.
|
|
1034
|
-
|
|
1035
|
-
## How levels map to Sentry
|
|
1036
|
-
|
|
1037
|
-
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
|
-
|
|
1039
|
-
| Logger level | Default | Sentry call |
|
|
1040
|
-
|---|---|---|
|
|
1041
|
-
| `fatal` | event | `captureException` for an `Error` message, else `captureMessage(…, "fatal")` |
|
|
1042
|
-
| `error` | event | `captureException` for an `Error` message, else `captureMessage(…, "error")` |
|
|
1043
|
-
| `warn` | event | `captureMessage(…, "warning")` |
|
|
1044
|
-
| `success` | breadcrumb | `addBreadcrumb({ level: "info" })` |
|
|
1045
|
-
| `info` | breadcrumb | `addBreadcrumb({ level: "info" })` |
|
|
1046
|
-
| `debug` | breadcrumb | `addBreadcrumb({ level: "debug" })` |
|
|
1047
|
-
|
|
1048
|
-
- **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
|
-
- **`module` / `action` become tags** and the entry's `context` becomes a structured Sentry context, both scoped to that single event via `withScope`.
|
|
1050
|
-
- **`success` has no Sentry severity** — it's reported as `info`.
|
|
1051
|
-
|
|
1052
|
-
### Tuning what becomes an event
|
|
1053
|
-
|
|
1054
|
-
```ts
|
|
1055
|
-
// Only errors create events; warnings drop to breadcrumbs.
|
|
1056
|
-
new SentryLog({ client: Sentry, eventLevels: ["error"] });
|
|
1057
|
-
|
|
1058
|
-
// Errors + warnings + an info stream as events (noisier, more quota).
|
|
1059
|
-
new SentryLog({ client: Sentry, eventLevels: ["error", "warn", "info"] });
|
|
1060
|
-
```
|
|
1061
|
-
|
|
1062
|
-
`levels` and `filter` from `BasicLogConfigurations` apply first — a channel-level `levels: ["error", "warn"]` drops everything else before it reaches Sentry at all.
|
|
1063
|
-
|
|
1064
|
-
## Draining on shutdown
|
|
1065
|
-
|
|
1066
|
-
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:
|
|
1067
|
-
|
|
1068
|
-
```ts
|
|
1069
|
-
async function shutdown() {
|
|
1070
|
-
await httpServer.close();
|
|
1071
|
-
await log.flush(); // SentryLog.flush() → Sentry.flush(flushTimeout)
|
|
1072
|
-
process.exit(0);
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
process.once("SIGTERM", shutdown);
|
|
1076
|
-
```
|
|
1077
|
-
|
|
1078
|
-
`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).
|
|
1079
|
-
|
|
1080
|
-
## If `@sentry/node` isn't installed
|
|
1081
|
-
|
|
1082
|
-
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.
|
|
1083
|
-
|
|
1084
|
-
## Config reference
|
|
1085
|
-
|
|
1086
|
-
| Option | Type | Default | Description |
|
|
1087
|
-
|---|---|---|---|
|
|
1088
|
-
| `client` | Sentry namespace / forwarder | — | Reuse an already-initialized Sentry instance. |
|
|
1089
|
-
| `options` | `SentryInitOptions` | — | Sentry init options (mirrors `@sentry/node`'s `NodeOptions`) — used when the channel owns Sentry. |
|
|
1090
|
-
| `eventLevels` | `LogLevel[]` | `["fatal", "error", "warn"]` | Levels sent as events; the rest become breadcrumbs. |
|
|
1091
|
-
| `flushTimeout` | `number` | `2000` | Ms `flush()` waits for the transport to drain. |
|
|
1092
|
-
| `levels`, `filter`, `dateFormat`, `redact` | — | — | Inherited from `BasicLogConfigurations`. |
|
|
1093
|
-
|
|
1094
|
-
## Don't do
|
|
1095
|
-
|
|
1096
|
-
- **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
|
-
- **Don't send every level as an event.** `eventLevels` with `info`/`debug`/`success` will flood your Sentry quota — keep them as breadcrumbs.
|
|
1098
|
-
- **Don't rely on `autoFlushOn` for Sentry.** It's synchronous; a network channel needs `await log.flush()`.
|
|
1099
|
-
|
|
1100
|
-
## See also
|
|
1101
|
-
|
|
1102
|
-
- [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) — `await log.flush()` on shutdown
|
|
1103
|
-
- [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md) — the console / file channels
|
|
1104
|
-
- [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md) — build your own sink
|
|
688
|
+
## `ConsoleLog`
|
|
1105
689
|
|
|
690
|
+
Zero config. Colored, icon-prefixed lines to the terminal.
|
|
1106
691
|
|
|
1107
|
-
|
|
692
|
+
```ts
|
|
693
|
+
import { ConsoleLog } from "@warlock.js/logger";
|
|
694
|
+
|
|
695
|
+
new ConsoleLog();
|
|
696
|
+
// ⚙ debug (10:22:00.000) [auth] [hashPassword] Hashing started
|
|
697
|
+
// ℹ info (10:22:01.482) [users] [register] New user created
|
|
698
|
+
// ✗ error (10:22:03.111) [payments] [charge] Card declined
|
|
699
|
+
```
|
|
1108
700
|
|
|
1109
|
-
|
|
1110
|
-
name
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
# Testing — code that logs, and asserting on log output
|
|
1115
|
-
|
|
1116
|
-
Two scenarios: **silencing the logger during tests** (most common) and **asserting that a specific log line was emitted**.
|
|
1117
|
-
|
|
1118
|
-
## Silence the logger during tests
|
|
1119
|
-
|
|
1120
|
-
Clear every channel once, globally. No output, no file handles, no noise.
|
|
1121
|
-
|
|
1122
|
-
```ts title="src/setupTests.ts"
|
|
1123
|
-
import { log } from "@warlock.js/logger";
|
|
1124
|
-
|
|
1125
|
-
log.setChannels([]);
|
|
1126
|
-
```
|
|
1127
|
-
|
|
1128
|
-
Wire it in Vitest:
|
|
1129
|
-
|
|
1130
|
-
```ts title="vitest.config.ts"
|
|
1131
|
-
import { defineConfig } from "vitest/config";
|
|
1132
|
-
|
|
1133
|
-
export default defineConfig({
|
|
1134
|
-
test: {
|
|
1135
|
-
setupFiles: ["src/setupTests.ts"],
|
|
1136
|
-
},
|
|
1137
|
-
});
|
|
1138
|
-
```
|
|
1139
|
-
|
|
1140
|
-
## Assert on log output — use a capturing channel
|
|
1141
|
-
|
|
1142
|
-
Don't spy on `console.log` and don't mock `log.info` — assert on what a channel actually received instead (see "Why not spy on `log.info`?" below). The cleanest pattern is a tiny channel that records what it sees:
|
|
1143
|
-
|
|
1144
|
-
```ts
|
|
1145
|
-
import { LogChannel } from "@warlock.js/logger";
|
|
1146
|
-
import type { LoggingData } from "@warlock.js/logger";
|
|
1147
|
-
|
|
1148
|
-
class CapturingChannel extends LogChannel {
|
|
1149
|
-
public name = "capture";
|
|
1150
|
-
public terminal = false;
|
|
1151
|
-
public received: LoggingData[] = [];
|
|
1152
|
-
public log(data: LoggingData) { this.received.push({ ...data }); }
|
|
1153
|
-
}
|
|
1154
|
-
```
|
|
1155
|
-
|
|
1156
|
-
### Test against the singleton
|
|
1157
|
-
|
|
1158
|
-
```ts
|
|
1159
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
1160
|
-
import { log } from "@warlock.js/logger";
|
|
1161
|
-
import { createUser } from "./users";
|
|
1162
|
-
|
|
1163
|
-
describe("createUser", () => {
|
|
1164
|
-
let capture: CapturingChannel;
|
|
1165
|
-
let originalChannels: typeof log.channels;
|
|
1166
|
-
|
|
1167
|
-
beforeEach(() => {
|
|
1168
|
-
capture = new CapturingChannel();
|
|
1169
|
-
originalChannels = log.channels;
|
|
1170
|
-
log.channels = [capture];
|
|
1171
|
-
});
|
|
1172
|
-
|
|
1173
|
-
afterEach(() => {
|
|
1174
|
-
log.channels = originalChannels;
|
|
1175
|
-
});
|
|
1176
|
-
|
|
1177
|
-
it("logs a success entry when the user is created", async () => {
|
|
1178
|
-
await createUser({ email: "a@b.com" });
|
|
1179
|
-
|
|
1180
|
-
expect(capture.received).toContainEqual(
|
|
1181
|
-
expect.objectContaining({
|
|
1182
|
-
type: "success",
|
|
1183
|
-
module: "users",
|
|
1184
|
-
action: "create",
|
|
1185
|
-
}),
|
|
1186
|
-
);
|
|
1187
|
-
});
|
|
1188
|
-
});
|
|
1189
|
-
```
|
|
1190
|
-
|
|
1191
|
-
### Test an isolated logger (avoid touching the singleton)
|
|
1192
|
-
|
|
1193
|
-
If the code under test accepts a logger via injection, create one per test:
|
|
1194
|
-
|
|
1195
|
-
```ts
|
|
1196
|
-
import { Logger } from "@warlock.js/logger";
|
|
1197
|
-
|
|
1198
|
-
const testLogger = new Logger();
|
|
1199
|
-
const capture = new CapturingChannel();
|
|
1200
|
-
testLogger.addChannel(capture);
|
|
1201
|
-
|
|
1202
|
-
await createUser({ email: "a@b.com" }, testLogger);
|
|
1203
|
-
|
|
1204
|
-
expect(capture.received[0]!.type).toBe("success");
|
|
1205
|
-
```
|
|
1206
|
-
|
|
1207
|
-
No cleanup needed — the local `Logger` is garbage-collected.
|
|
1208
|
-
|
|
1209
|
-
## Why not spy on `log.info`?
|
|
1210
|
-
|
|
1211
|
-
`log` is a plain `Logger` instance (`export const log = new Logger()`) and every level method lives on the prototype, so `vi.spyOn(log, "info")` *does* technically work. Prefer the capturing channel anyway:
|
|
1212
|
-
|
|
1213
|
-
- A spy on `log.info` proves the method was **called**, not that an entry was **delivered** — it skips the whole pipeline (`minLevel` floor, redaction, per-channel `levels` / `filter`). A capturing channel asserts on the entry your code under test actually produced after all of that ran.
|
|
1214
|
-
- The `log` singleton is shared global state. A spy you forget to `mockRestore()` leaks into the next test; swapping `log.channels` and restoring it in `afterEach` is the same amount of code and isolates cleanly.
|
|
1215
|
-
- Code that logs through `log.error(...)` and the bare object form `log.log({ type, ... })` both land in channels, but only the level shortcut goes through `log.info` — a channel catches both.
|
|
1216
|
-
|
|
1217
|
-
So capture through a channel as shown above; reach for a method spy only when you specifically want to assert "this exact shortcut was invoked".
|
|
1218
|
-
|
|
1219
|
-
## Testing a custom channel
|
|
1220
|
-
|
|
1221
|
-
Write specs against the channel directly; don't route through `Logger`:
|
|
1222
|
-
|
|
1223
|
-
```ts
|
|
1224
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1225
|
-
import { SlackLog } from "./slack-log";
|
|
1226
|
-
|
|
1227
|
-
describe("SlackLog", () => {
|
|
1228
|
-
it("skips non-error levels", async () => {
|
|
1229
|
-
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response());
|
|
1230
|
-
|
|
1231
|
-
const channel = new SlackLog({ webhookUrl: "https://test", levels: ["error"] });
|
|
1232
|
-
await channel.log({ type: "info", module: "x", action: "y", message: "z" });
|
|
1233
|
-
|
|
1234
|
-
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1235
|
-
});
|
|
1236
|
-
});
|
|
1237
|
-
```
|
|
1238
|
-
|
|
1239
|
-
## Testing `FileLog` and `JSONFileLog`
|
|
1240
|
-
|
|
1241
|
-
Use real temp directories — it's the only way to exercise file IO, rotation, chunking, and JSON I/O with fidelity:
|
|
1242
|
-
|
|
1243
|
-
```ts
|
|
1244
|
-
import fs from "fs";
|
|
1245
|
-
import os from "os";
|
|
1246
|
-
import path from "path";
|
|
1247
|
-
import { randomUUID } from "node:crypto";
|
|
1248
|
-
|
|
1249
|
-
function tempDir() {
|
|
1250
|
-
const dir = path.join(os.tmpdir(), "logger-test", randomUUID());
|
|
1251
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1252
|
-
return dir;
|
|
1253
|
-
}
|
|
1254
|
-
```
|
|
1255
|
-
|
|
1256
|
-
Clean up in `afterEach(() => fs.rmSync(dir, { recursive: true, force: true }))`.
|
|
1257
|
-
|
|
1258
|
-
## Waiting for async init
|
|
1259
|
-
|
|
1260
|
-
`LogChannel.init()` runs inside a `setTimeout(0)`. Before asserting on post-init behavior, yield once:
|
|
1261
|
-
|
|
1262
|
-
```ts
|
|
1263
|
-
const channel = new FileLog({ storagePath: tempDir() });
|
|
1264
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
1265
|
-
// Now `channel.isInitialized` is true and it's safe to call `channel.log(...)` for real I/O.
|
|
1266
|
-
```
|
|
1267
|
-
|
|
1268
|
-
## Testing `captureAnyUnhandledRejection`
|
|
1269
|
-
|
|
1270
|
-
Don't actually throw unhandled rejections in tests — emit the listener directly:
|
|
1271
|
-
|
|
1272
|
-
```ts
|
|
1273
|
-
captureAnyUnhandledRejection();
|
|
1274
|
-
process.emit("unhandledRejection", new Error("test"), Promise.resolve());
|
|
1275
|
-
```
|
|
1276
|
-
|
|
1277
|
-
The `uncaughtException` path additionally calls `process.exit(1)`, so stub it (or pass `{ exitOnUncaughtException: false }`) before emitting — otherwise the emitted exception tears the test runner down:
|
|
1278
|
-
|
|
1279
|
-
```ts
|
|
1280
|
-
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
1281
|
-
captureAnyUnhandledRejection();
|
|
1282
|
-
process.emit("uncaughtException", new Error("test"), "uncaughtException");
|
|
1283
|
-
```
|
|
1284
|
-
|
|
1285
|
-
See [`@warlock.js/logger/capture-unhandled-errors/SKILL.md`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) for a full example.
|
|
701
|
+
Properties:
|
|
702
|
+
- `name = "console"`, `terminal = true`
|
|
703
|
+
- Accepts `ConsoleLogConfig` — `levels`, `filter`, `dateFormat`, `showContext`, `contextDepth`
|
|
704
|
+
- If `message` is an object, a second `console.log(message)` is issued so Node's inspector can expand it
|
|
1286
705
|
|
|
706
|
+
### Showing context
|
|
1287
707
|
|
|
1288
|
-
|
|
708
|
+
By default `ConsoleLog` drops the `context` payload (the file/JSON channels still keep it). Flip `showContext: true` to render it on a second line — useful in development:
|
|
709
|
+
|
|
710
|
+
```ts
|
|
711
|
+
new ConsoleLog({ showContext: true });
|
|
1289
712
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
# Helpers — `assert`, `timer`
|
|
1296
|
-
|
|
1297
|
-
Two small DX shortcuts on every `Logger` (and the bound `log` helper). They route through the normal log pipeline — every channel sees what they emit.
|
|
1298
|
-
|
|
1299
|
-
## `log.assert(condition, module, action, message, context?)`
|
|
1300
|
-
|
|
1301
|
-
Logs an `error` entry when `condition` is falsy. Genuinely free in the happy path: when the condition is truthy, the entry is never built and channels are never invoked.
|
|
1302
|
-
|
|
1303
|
-
```ts
|
|
1304
|
-
log.assert(user !== null, "auth", "session", "user vanished mid-flight", {
|
|
1305
|
-
sessionId,
|
|
1306
|
-
});
|
|
1307
|
-
|
|
1308
|
-
// truthy → no log call
|
|
1309
|
-
// falsy → equivalent to log.error("auth", "session", "user vanished...", { sessionId })
|
|
1310
|
-
```
|
|
1311
|
-
|
|
1312
|
-
The level is implicitly `error` — assertions express failures, not warnings. If you need a non-error level, use `log.error` / `log.warn` directly with your own `if`.
|
|
1313
|
-
|
|
1314
|
-
### Why not `console.assert`?
|
|
1315
|
-
|
|
1316
|
-
`console.assert` writes to stderr only and bypasses your file/JSON channels. `log.assert` runs through the logger pipeline, so a failed assertion is captured by every persistent channel you've configured. See [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md).
|
|
1317
|
-
|
|
1318
|
-
## `log.timer(module, action)`
|
|
1319
|
-
|
|
1320
|
-
Returns an end-function. Calling it emits an `info` entry with `completed in <ms>ms` and a `durationMs` field in `context`.
|
|
1321
|
-
|
|
1322
|
-
```ts
|
|
1323
|
-
const end = log.timer("db", "users.findById");
|
|
1324
|
-
const user = await usersRepo.findById(id);
|
|
1325
|
-
end({ id, found: !!user });
|
|
1326
|
-
// ℹ info [db] [users.findById] completed in 12ms
|
|
1327
|
-
// ↳ { durationMs: 12, id: "abc", found: true } (when ConsoleLog has showContext: true)
|
|
1328
|
-
```
|
|
1329
|
-
|
|
1330
|
-
Common patterns:
|
|
1331
|
-
|
|
1332
|
-
```ts
|
|
1333
|
-
// Around an HTTP handler
|
|
1334
|
-
async function handle(req) {
|
|
1335
|
-
const end = log.timer("http", `${req.method} ${req.url}`);
|
|
1336
|
-
try {
|
|
1337
|
-
return await runHandler(req);
|
|
1338
|
-
} finally {
|
|
1339
|
-
end({ status: res.statusCode });
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
// Around a job
|
|
1344
|
-
const end = log.timer("jobs", "nightly-report");
|
|
1345
|
-
await report.run();
|
|
1346
|
-
end({ rowsProcessed: report.rowCount });
|
|
1347
|
-
```
|
|
1348
|
-
|
|
1349
|
-
`end()` can be called more than once if you want intermediate checkpoints — each call emits a fresh entry with the duration measured from the original `timer()` call.
|
|
1350
|
-
|
|
1351
|
-
### Caveats
|
|
1352
|
-
|
|
1353
|
-
- The duration is `Date.now()` based — millisecond resolution. For sub-millisecond profiling, reach for `performance.now()` directly.
|
|
1354
|
-
- The end-function captures `this` at construction; calling it after the logger is reconfigured still routes through the same `Logger` instance.
|
|
1355
|
-
- `log.timer` shorthand binds to the singleton — see [`@warlock.js/logger/test-logging-code/SKILL.md`](@warlock.js/logger/test-logging-code/SKILL.md) for how to swap channels per test.
|
|
713
|
+
log.info("payments", "charge", "card declined", { userId: 42, amount: 1999 });
|
|
714
|
+
// ℹ info (…) [payments] [charge] card declined
|
|
715
|
+
// ↳ { userId: 42, amount: 1999 }
|
|
716
|
+
```
|
|
1356
717
|
|
|
718
|
+
Tune `contextDepth` (default `4`) to clamp how deep `util.inspect` recurses into nested objects.
|
|
1357
719
|
|
|
1358
|
-
##
|
|
720
|
+
## `FileLog`
|
|
721
|
+
|
|
722
|
+
Plain text. Buffers in memory, flushes to disk periodically.
|
|
723
|
+
|
|
724
|
+
```ts
|
|
725
|
+
import { FileLog } from "@warlock.js/logger";
|
|
726
|
+
|
|
727
|
+
new FileLog({
|
|
728
|
+
storagePath: "./storage/logs", // default: process.cwd() + "/storage/logs"
|
|
729
|
+
name: "app", // default: "app"
|
|
730
|
+
extension: "log", // default: "log"
|
|
731
|
+
chunk: "daily", // "single" (default) | "daily" | "hourly"
|
|
732
|
+
rotate: true, // default: true
|
|
733
|
+
maxFileSize: 10 * 1024 * 1024, // default: 10MB — triggers rotation
|
|
734
|
+
maxMessagesToWrite: 100, // default: 100 — flush threshold
|
|
735
|
+
groupBy: ["level", "module"], // optional subdirectory nesting
|
|
736
|
+
});
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
Line format: `[date time] [level] [module][action]: message` — or a `[trace]` block when `message` is an `Error`.
|
|
740
|
+
|
|
741
|
+
### Key gotchas
|
|
742
|
+
|
|
743
|
+
- **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-YYYY.log`. Combined with `rotate: true`, rotated archives get `Date.now()` suffixed.
|
|
745
|
+
- **`groupBy` nests directories.** `groupBy: ["level", "module"]` produces `storage/logs/error/payments/app.log`. Order matters.
|
|
746
|
+
- **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
|
+
|
|
748
|
+
## `JSONFileLog`
|
|
749
|
+
|
|
750
|
+
Subclass of `FileLog` — same buffering, chunking, rotation, grouping. Output is a JSON object with a `messages` array:
|
|
751
|
+
|
|
752
|
+
```json
|
|
753
|
+
{
|
|
754
|
+
"messages": [
|
|
755
|
+
{
|
|
756
|
+
"content": "Card declined",
|
|
757
|
+
"level": "error",
|
|
758
|
+
"date": "15-03-2024 10:22:03",
|
|
759
|
+
"module": "payments",
|
|
760
|
+
"action": "charge",
|
|
761
|
+
"stack": [
|
|
762
|
+
"Error: Card declined",
|
|
763
|
+
" at chargeCard (/app/src/payments.ts:42:11)"
|
|
764
|
+
]
|
|
765
|
+
}
|
|
766
|
+
]
|
|
767
|
+
}
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Differences from `FileLog`:
|
|
771
|
+
- `name = "fileJson"` (**not** `"json"` — use this exact string for `log.channel("fileJson")`)
|
|
772
|
+
- `extension` is always `"json"` — the option is silently ignored
|
|
773
|
+
- Error `stack` is stored as `string[]` (split on newlines) — easy to query in aggregators
|
|
774
|
+
- `content` holds the original user-supplied `message` (not a pre-formatted line)
|
|
775
|
+
- Corrupted existing file → reinitialized to `{ messages: [] }` on next write (does not throw)
|
|
776
|
+
- **Safe serialization by construction.** All writes go through `safe-stable-stringify` with a custom `Error` replacer — circular refs become `"[Circular]"`, BigInt is stringified, functions/symbols are dropped, nested `Error` instances expand to `{ name, message, stack, ...enumerable }`. A context payload with a class graph or circular reference will never throw during the write.
|
|
777
|
+
|
|
778
|
+
## `SentryLog`
|
|
779
|
+
|
|
780
|
+
Forwards entries to Sentry — `error` / `warn` become events (`captureException` for `Error` messages, `captureMessage` otherwise), every other level a breadcrumb (no quota). `@sentry/node` is an optional peer, lazily imported; pass an existing `client` or `options`.
|
|
781
|
+
|
|
782
|
+
```ts
|
|
783
|
+
import * as Sentry from "@sentry/node";
|
|
784
|
+
import { SentryLog } from "@warlock.js/logger";
|
|
785
|
+
|
|
786
|
+
new SentryLog({ client: Sentry, eventLevels: ["error", "warn"] });
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
Full guide — level mapping, init modes, shutdown draining: [`ship-logs-to-sentry`](@warlock.js/logger/ship-logs-to-sentry/SKILL.md).
|
|
790
|
+
|
|
791
|
+
## Shared config — `BasicLogConfigurations`
|
|
792
|
+
|
|
793
|
+
Every channel constructor accepts at minimum:
|
|
794
|
+
|
|
795
|
+
```ts
|
|
796
|
+
type BasicLogConfigurations = {
|
|
797
|
+
levels?: LogLevel[]; // whitelist — omit or [] to allow all
|
|
798
|
+
filter?: (data: LoggingData) => boolean; // custom predicate
|
|
799
|
+
dateFormat?: { date?: string; time?: string }; // Day.js format strings
|
|
800
|
+
context?: (data) => Promise<Record<string, any>>; // reserved — not yet read
|
|
801
|
+
};
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Concrete file channels extend this with their storage/chunk/rotate/groupBy options via intersection.
|
|
805
|
+
|
|
806
|
+
## Picking a channel by name at runtime
|
|
807
|
+
|
|
808
|
+
```ts
|
|
809
|
+
log.channel("console"); // → ConsoleLog | undefined
|
|
810
|
+
log.channel("file"); // → FileLog | undefined
|
|
811
|
+
log.channel("fileJson"); // ← note the name — NOT "json"
|
|
812
|
+
log.channel("sentry"); // → SentryLog | undefined
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
If two channels share a `name`, only one is reachable this way — the search returns the first match.
|
|
816
|
+
|
|
817
|
+
## See also
|
|
818
|
+
|
|
819
|
+
- [`@warlock.js/logger/configure-logger/SKILL.md`](@warlock.js/logger/configure-logger/SKILL.md) — registering channels at startup
|
|
820
|
+
- [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md) — `levels` and `filter` config in detail
|
|
821
|
+
- [`@warlock.js/logger/ship-logs-to-sentry/SKILL.md`](@warlock.js/logger/ship-logs-to-sentry/SKILL.md) — the `SentryLog` channel in depth
|
|
822
|
+
- [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md) — extending `LogChannel` for custom sinks
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
## redact-sensitive-log-fields `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`
|
|
826
|
+
|
|
827
|
+
---
|
|
828
|
+
name: redact-sensitive-log-fields
|
|
829
|
+
description: 'Strip secrets from log output — a built-in secret-key denylist on by default (DEFAULT_REDACT_KEYS), plus two-layer additive redaction via log.configure({redact: {paths, keys}}) (logger floor) + per-channel redact (more on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `keys`, `defaultKeys`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging", "turn off default redaction"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing libs `pino.redact`, `fast-redact`.'
|
|
830
|
+
---
|
|
831
|
+
|
|
832
|
+
# Redaction — keeping secrets out of logs
|
|
833
|
+
|
|
834
|
+
Three layers: a **built-in key denylist that is on by default**, plus opt-in path globs configured at the logger and/or per channel.
|
|
835
|
+
|
|
836
|
+
## Layer 0 — the default denylist (since 4.15.0, no configuration needed)
|
|
837
|
+
|
|
838
|
+
Common secret **key names** are censored at any depth of `context`, `message`, and an `Error`'s own enumerable properties — before any of your config runs:
|
|
839
|
+
|
|
840
|
+
```ts
|
|
841
|
+
log.error("auth", "login", "failed", { headers: req.headers, body: req.body });
|
|
842
|
+
// context.headers.authorization → "[REDACTED]"
|
|
843
|
+
// context.body.password → "[REDACTED]"
|
|
844
|
+
// context.body.email → untouched
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
Keys are matched **exactly**, on a normalized form (lower-cased, separators stripped) — so one entry covers `apiKey` / `api_key` / `API-KEY` / `x-api-key`. It is not substring matching: `tokenCount` and `passwordUpdatedAt` survive. Read the exact set from `DEFAULT_REDACT_KEYS`.
|
|
848
|
+
|
|
849
|
+
```ts
|
|
850
|
+
import { DEFAULT_REDACT_KEYS } from "@warlock.js/logger";
|
|
851
|
+
|
|
852
|
+
log.configure({
|
|
853
|
+
redact: {
|
|
854
|
+
keys: ["internalRef"], // union with the built-in set
|
|
855
|
+
defaultKeys: false, // opt out of the built-in set entirely
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
`defaultKeys: false` is an escape hatch, not a tuning knob — it restores the pre-4.15.0 behavior where a `password` in `context` reaches every sink in cleartext. Prefer adding a function `censor` if you only need to keep a prefix.
|
|
861
|
+
|
|
862
|
+
**A channel cannot turn the default set off** (it can only add keys, or turn it back *on* if the logger-wide config disabled it) — same additive-only contract as paths, below. And `log.setRedact(undefined)` clears *your* paths, not the default denylist.
|
|
863
|
+
|
|
864
|
+
Not reachable by any layer: secrets interpolated into a `message` string (`` `token=${t}` ``), `Map`/`Set`/`Buffer` contents, and getter-backed or non-enumerable properties.
|
|
865
|
+
|
|
866
|
+
## Layers 1 & 2 — path globs, opt-in
|
|
867
|
+
|
|
868
|
+
For anything the denylist can't name by key — a secret under an app-specific key, or a value you want partially masked rather than blanked.
|
|
869
|
+
|
|
870
|
+
The model in one line:
|
|
871
|
+
|
|
872
|
+
> Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
|
|
873
|
+
|
|
874
|
+
That guarantee is the whole point — once you've set `password` to redact at the logger, you can audit one place to know nothing leaks it, regardless of how many channels you add.
|
|
875
|
+
|
|
876
|
+
## Logger-wide floor
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
import { log } from "@warlock.js/logger";
|
|
880
|
+
|
|
881
|
+
log.configure({
|
|
882
|
+
redact: {
|
|
883
|
+
paths: [
|
|
884
|
+
"context.password",
|
|
885
|
+
"context.*.token",
|
|
886
|
+
"context.headers.authorization",
|
|
887
|
+
],
|
|
888
|
+
censor: "[REDACTED]", // default — string or function
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// runtime equivalent:
|
|
893
|
+
log.setRedact({ paths: ["context.password"] });
|
|
894
|
+
log.setRedact(undefined); // clear your paths (the default denylist stays on)
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
|
|
898
|
+
|
|
899
|
+
## Per-channel additive
|
|
900
|
+
|
|
901
|
+
```ts
|
|
902
|
+
new SlackChannel({
|
|
903
|
+
webhook: "...",
|
|
904
|
+
redact: {
|
|
905
|
+
paths: ["context.user.email", "context.metadata.*"],
|
|
906
|
+
// censor inherited from logger-wide when omitted
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
The channel's `paths` are **merged** with the logger floor — the channel runs a single combined redact pass, never replaces the floor. The channel's `censor` (if provided) wins for both its own and the logger's paths in this channel only; the logger floor still uses its own censor for other channels.
|
|
912
|
+
|
|
913
|
+
### When to set redact per-channel
|
|
914
|
+
|
|
915
|
+
- Loud destinations with broader audiences (Slack, Discord, error trackers, anything off your machine) — redact more aggressively.
|
|
916
|
+
- Local-only destinations (FileLog you alone read, the dev terminal) — keep the floor minimal so you can debug.
|
|
917
|
+
|
|
918
|
+
### When NOT to set it
|
|
919
|
+
|
|
920
|
+
If you want raw context in your dev terminal, **don't add redact at the logger level** — set it only on the file/JSON/network channels. Logger-wide is the floor, so it applies everywhere; you can't opt a single channel out.
|
|
921
|
+
|
|
922
|
+
## Path syntax
|
|
923
|
+
|
|
924
|
+
Paths are dotted glob patterns evaluated against the full `LoggingData`:
|
|
925
|
+
|
|
926
|
+
```
|
|
927
|
+
type LoggingData = {
|
|
928
|
+
type: "info" | ...,
|
|
929
|
+
module: string,
|
|
930
|
+
action: string,
|
|
931
|
+
message: any, // ← prefix paths with "message." to redact here
|
|
932
|
+
context?: object, // ← prefix paths with "context." to redact here
|
|
933
|
+
};
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
| Pattern | Matches |
|
|
937
|
+
| --- | --- |
|
|
938
|
+
| `context.password` | exactly `data.context.password` |
|
|
939
|
+
| `context.*.token` | `data.context.<any>.token` (one segment in between) |
|
|
940
|
+
| `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
|
|
941
|
+
| `message.apiKey` | when message is an object, `data.message.apiKey` |
|
|
942
|
+
| `context.users.*.token` | array element redaction (`*` matches indices too) |
|
|
943
|
+
|
|
944
|
+
Wildcards:
|
|
945
|
+
|
|
946
|
+
- `*` — exactly one segment (any object key, any array index).
|
|
947
|
+
- `**` — zero or more segments, greedily; matches at any depth.
|
|
948
|
+
|
|
949
|
+
## Censor variants
|
|
950
|
+
|
|
951
|
+
```ts
|
|
952
|
+
// String — replace with a literal.
|
|
953
|
+
{ censor: "[REDACTED]" }
|
|
954
|
+
{ censor: "***" }
|
|
955
|
+
|
|
956
|
+
// Function — receives original value + dotted path, returns the replacement.
|
|
957
|
+
{
|
|
958
|
+
censor: (value, path) => {
|
|
959
|
+
if (typeof value !== "string") return "[REDACTED]";
|
|
960
|
+
return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***";
|
|
961
|
+
},
|
|
962
|
+
}
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
Function censors are called for every match — keep them cheap. The path is the actual matched location (e.g. `"context.users.0.token"` for an array hit).
|
|
966
|
+
|
|
967
|
+
## Immutability
|
|
968
|
+
|
|
969
|
+
`applyRedact` always returns a deep clone — your input data is never mutated. `Date` and `Error` instances are reconstructed (so `instanceof` checks still work). Circular references are tolerated.
|
|
970
|
+
|
|
971
|
+
## What about the `message` field?
|
|
972
|
+
|
|
973
|
+
If `message` is a plain object, paths under `message.*` work as expected. If `message` is a string (the most common case), redaction won't scan it — string scrubbing requires regex and is out of scope for this primitive. Wrap secrets in `context` and they'll be redacted reliably.
|
|
974
|
+
|
|
975
|
+
## Performance notes
|
|
976
|
+
|
|
977
|
+
- **No redact configured** → the default denylist still runs (since 4.15.0): a cheap presence scan for a denylisted key on every `log()` call, with the deep clone + censor pass skipped entirely when nothing matches. Only `{ defaultKeys: false }` (no `paths`, no extra `keys`) gets back to zero work.
|
|
978
|
+
- **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
|
|
979
|
+
- **Channel adds paths** → that channel re-clones from the original input and runs the merged pass once. Other channels still share the cheaper logger-wide clone.
|
|
980
|
+
- Each path is matched independently; cost grows linearly with `paths.length`.
|
|
981
|
+
|
|
982
|
+
For most apps with `<10` redact paths and shallow context, the cost is below 100µs per entry. If you're logging millions of entries per second through paths like `**.something`, profile before scaling up — `**` is the only pattern that recurses through every key.
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
## ship-logs-to-sentry `@warlock.js/logger/ship-logs-to-sentry/SKILL.md`
|
|
986
|
+
|
|
987
|
+
---
|
|
988
|
+
name: ship-logs-to-sentry
|
|
989
|
+
description: 'Forward log entries to Sentry with the SentryLog channel — error/warn become events (captureException/captureMessage), every other level a breadcrumb (no quota). @sentry/node is an OPTIONAL peer, lazily imported. Triggers: `SentryLog`, `@sentry/node`, `eventLevels`, `flushTimeout`, `Sentry.flush`, `captureException`, `addBreadcrumb`, `withScope`; "send logs to Sentry", "report errors to Sentry", "Sentry log channel", "Sentry breadcrumbs from logs", "log channel for Sentry"; typical import `import { SentryLog } from "@warlock.js/logger"`. Skip: file/console channels — `@warlock.js/logger/pick-log-channel/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; graceful-shutdown flushing — `@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`; Slack alerting recipe.'
|
|
990
|
+
---
|
|
991
|
+
|
|
992
|
+
# Ship logs to Sentry — the `SentryLog` channel
|
|
993
|
+
|
|
994
|
+
`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:
|
|
995
|
+
|
|
996
|
+
```bash
|
|
997
|
+
npm install @sentry/node
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
## Two ways to wire it
|
|
1001
|
+
|
|
1002
|
+
### Reuse an existing Sentry client (existing apps)
|
|
1003
|
+
|
|
1004
|
+
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:
|
|
1005
|
+
|
|
1006
|
+
```ts
|
|
1007
|
+
import * as Sentry from "@sentry/node";
|
|
1008
|
+
import { log, SentryLog } from "@warlock.js/logger";
|
|
1009
|
+
|
|
1010
|
+
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production" });
|
|
1011
|
+
|
|
1012
|
+
log.addChannel(new SentryLog({ client: Sentry }));
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
### Let the channel initialize Sentry (new apps)
|
|
1016
|
+
|
|
1017
|
+
Pass `options`; the channel lazily imports `@sentry/node` and calls `Sentry.init` once — guarded so it never clobbers an existing client:
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
import { log, SentryLog } from "@warlock.js/logger";
|
|
1021
|
+
|
|
1022
|
+
log.addChannel(
|
|
1023
|
+
new SentryLog({
|
|
1024
|
+
options: {
|
|
1025
|
+
dsn: process.env.SENTRY_DSN,
|
|
1026
|
+
environment: process.env.NODE_ENV,
|
|
1027
|
+
release: process.env.GIT_SHA,
|
|
1028
|
+
},
|
|
1029
|
+
}),
|
|
1030
|
+
);
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
With neither `client` nor `options`, the channel reuses whatever global Sentry client the host already initialized.
|
|
1034
|
+
|
|
1035
|
+
## How levels map to Sentry
|
|
1036
|
+
|
|
1037
|
+
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
|
+
|
|
1039
|
+
| Logger level | Default | Sentry call |
|
|
1040
|
+
|---|---|---|
|
|
1041
|
+
| `fatal` | event | `captureException` for an `Error` message, else `captureMessage(…, "fatal")` |
|
|
1042
|
+
| `error` | event | `captureException` for an `Error` message, else `captureMessage(…, "error")` |
|
|
1043
|
+
| `warn` | event | `captureMessage(…, "warning")` |
|
|
1044
|
+
| `success` | breadcrumb | `addBreadcrumb({ level: "info" })` |
|
|
1045
|
+
| `info` | breadcrumb | `addBreadcrumb({ level: "info" })` |
|
|
1046
|
+
| `debug` | breadcrumb | `addBreadcrumb({ level: "debug" })` |
|
|
1047
|
+
|
|
1048
|
+
- **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
|
+
- **`module` / `action` become tags** and the entry's `context` becomes a structured Sentry context, both scoped to that single event via `withScope`.
|
|
1050
|
+
- **`success` has no Sentry severity** — it's reported as `info`.
|
|
1051
|
+
|
|
1052
|
+
### Tuning what becomes an event
|
|
1053
|
+
|
|
1054
|
+
```ts
|
|
1055
|
+
// Only errors create events; warnings drop to breadcrumbs.
|
|
1056
|
+
new SentryLog({ client: Sentry, eventLevels: ["error"] });
|
|
1057
|
+
|
|
1058
|
+
// Errors + warnings + an info stream as events (noisier, more quota).
|
|
1059
|
+
new SentryLog({ client: Sentry, eventLevels: ["error", "warn", "info"] });
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
`levels` and `filter` from `BasicLogConfigurations` apply first — a channel-level `levels: ["error", "warn"]` drops everything else before it reaches Sentry at all.
|
|
1063
|
+
|
|
1064
|
+
## Draining on shutdown
|
|
1065
|
+
|
|
1066
|
+
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:
|
|
1067
|
+
|
|
1068
|
+
```ts
|
|
1069
|
+
async function shutdown() {
|
|
1070
|
+
await httpServer.close();
|
|
1071
|
+
await log.flush(); // SentryLog.flush() → Sentry.flush(flushTimeout)
|
|
1072
|
+
process.exit(0);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
process.once("SIGTERM", shutdown);
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
`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).
|
|
1079
|
+
|
|
1080
|
+
## If `@sentry/node` isn't installed
|
|
1081
|
+
|
|
1082
|
+
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.
|
|
1083
|
+
|
|
1084
|
+
## Config reference
|
|
1085
|
+
|
|
1086
|
+
| Option | Type | Default | Description |
|
|
1087
|
+
|---|---|---|---|
|
|
1088
|
+
| `client` | Sentry namespace / forwarder | — | Reuse an already-initialized Sentry instance. |
|
|
1089
|
+
| `options` | `SentryInitOptions` | — | Sentry init options (mirrors `@sentry/node`'s `NodeOptions`) — used when the channel owns Sentry. |
|
|
1090
|
+
| `eventLevels` | `LogLevel[]` | `["fatal", "error", "warn"]` | Levels sent as events; the rest become breadcrumbs. |
|
|
1091
|
+
| `flushTimeout` | `number` | `2000` | Ms `flush()` waits for the transport to drain. |
|
|
1092
|
+
| `levels`, `filter`, `dateFormat`, `redact` | — | — | Inherited from `BasicLogConfigurations`. |
|
|
1093
|
+
|
|
1094
|
+
## Don't do
|
|
1095
|
+
|
|
1096
|
+
- **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
|
+
- **Don't send every level as an event.** `eventLevels` with `info`/`debug`/`success` will flood your Sentry quota — keep them as breadcrumbs.
|
|
1098
|
+
- **Don't rely on `autoFlushOn` for Sentry.** It's synchronous; a network channel needs `await log.flush()`.
|
|
1099
|
+
|
|
1100
|
+
## See also
|
|
1101
|
+
|
|
1102
|
+
- [`@warlock.js/logger/flush-logs-on-shutdown/SKILL.md`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md) — `await log.flush()` on shutdown
|
|
1103
|
+
- [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md) — the console / file channels
|
|
1104
|
+
- [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md) — build your own sink
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
## test-logging-code `@warlock.js/logger/test-logging-code/SKILL.md`
|
|
1108
|
+
|
|
1109
|
+
---
|
|
1110
|
+
name: test-logging-code
|
|
1111
|
+
description: 'Test code that touches the logger — silence globally via log.setChannels([]) in setupFiles, assert specific log lines via a capturing LogChannel subclass (prefer it over vi.spyOn — it asserts on delivered entries, not just method calls, and isolates the shared singleton cleanly). Triggers: `log.setChannels`, `LogChannel`, `LoggingData`, `Logger`, `log.channels`; "silence logger in vitest", "assert a log line was emitted", "capture log output in tests", "test code that logs"; typical import `import { log, Logger, LogChannel, type LoggingData } from "@warlock.js/logger"`. Skip: custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `vi.spyOn(console)`, `jest.spyOn`.'
|
|
1112
|
+
---
|
|
1113
|
+
|
|
1114
|
+
# Testing — code that logs, and asserting on log output
|
|
1115
|
+
|
|
1116
|
+
Two scenarios: **silencing the logger during tests** (most common) and **asserting that a specific log line was emitted**.
|
|
1117
|
+
|
|
1118
|
+
## Silence the logger during tests
|
|
1119
|
+
|
|
1120
|
+
Clear every channel once, globally. No output, no file handles, no noise.
|
|
1121
|
+
|
|
1122
|
+
```ts title="src/setupTests.ts"
|
|
1123
|
+
import { log } from "@warlock.js/logger";
|
|
1124
|
+
|
|
1125
|
+
log.setChannels([]);
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
Wire it in Vitest:
|
|
1129
|
+
|
|
1130
|
+
```ts title="vitest.config.ts"
|
|
1131
|
+
import { defineConfig } from "vitest/config";
|
|
1132
|
+
|
|
1133
|
+
export default defineConfig({
|
|
1134
|
+
test: {
|
|
1135
|
+
setupFiles: ["src/setupTests.ts"],
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
## Assert on log output — use a capturing channel
|
|
1141
|
+
|
|
1142
|
+
Don't spy on `console.log` and don't mock `log.info` — assert on what a channel actually received instead (see "Why not spy on `log.info`?" below). The cleanest pattern is a tiny channel that records what it sees:
|
|
1143
|
+
|
|
1144
|
+
```ts
|
|
1145
|
+
import { LogChannel } from "@warlock.js/logger";
|
|
1146
|
+
import type { LoggingData } from "@warlock.js/logger";
|
|
1147
|
+
|
|
1148
|
+
class CapturingChannel extends LogChannel {
|
|
1149
|
+
public name = "capture";
|
|
1150
|
+
public terminal = false;
|
|
1151
|
+
public received: LoggingData[] = [];
|
|
1152
|
+
public log(data: LoggingData) { this.received.push({ ...data }); }
|
|
1153
|
+
}
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
### Test against the singleton
|
|
1157
|
+
|
|
1158
|
+
```ts
|
|
1159
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
1160
|
+
import { log } from "@warlock.js/logger";
|
|
1161
|
+
import { createUser } from "./users";
|
|
1162
|
+
|
|
1163
|
+
describe("createUser", () => {
|
|
1164
|
+
let capture: CapturingChannel;
|
|
1165
|
+
let originalChannels: typeof log.channels;
|
|
1166
|
+
|
|
1167
|
+
beforeEach(() => {
|
|
1168
|
+
capture = new CapturingChannel();
|
|
1169
|
+
originalChannels = log.channels;
|
|
1170
|
+
log.channels = [capture];
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
afterEach(() => {
|
|
1174
|
+
log.channels = originalChannels;
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
it("logs a success entry when the user is created", async () => {
|
|
1178
|
+
await createUser({ email: "a@b.com" });
|
|
1179
|
+
|
|
1180
|
+
expect(capture.received).toContainEqual(
|
|
1181
|
+
expect.objectContaining({
|
|
1182
|
+
type: "success",
|
|
1183
|
+
module: "users",
|
|
1184
|
+
action: "create",
|
|
1185
|
+
}),
|
|
1186
|
+
);
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
### Test an isolated logger (avoid touching the singleton)
|
|
1192
|
+
|
|
1193
|
+
If the code under test accepts a logger via injection, create one per test:
|
|
1194
|
+
|
|
1195
|
+
```ts
|
|
1196
|
+
import { Logger } from "@warlock.js/logger";
|
|
1197
|
+
|
|
1198
|
+
const testLogger = new Logger();
|
|
1199
|
+
const capture = new CapturingChannel();
|
|
1200
|
+
testLogger.addChannel(capture);
|
|
1201
|
+
|
|
1202
|
+
await createUser({ email: "a@b.com" }, testLogger);
|
|
1203
|
+
|
|
1204
|
+
expect(capture.received[0]!.type).toBe("success");
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
No cleanup needed — the local `Logger` is garbage-collected.
|
|
1208
|
+
|
|
1209
|
+
## Why not spy on `log.info`?
|
|
1210
|
+
|
|
1211
|
+
`log` is a plain `Logger` instance (`export const log = new Logger()`) and every level method lives on the prototype, so `vi.spyOn(log, "info")` *does* technically work. Prefer the capturing channel anyway:
|
|
1212
|
+
|
|
1213
|
+
- A spy on `log.info` proves the method was **called**, not that an entry was **delivered** — it skips the whole pipeline (`minLevel` floor, redaction, per-channel `levels` / `filter`). A capturing channel asserts on the entry your code under test actually produced after all of that ran.
|
|
1214
|
+
- The `log` singleton is shared global state. A spy you forget to `mockRestore()` leaks into the next test; swapping `log.channels` and restoring it in `afterEach` is the same amount of code and isolates cleanly.
|
|
1215
|
+
- Code that logs through `log.error(...)` and the bare object form `log.log({ type, ... })` both land in channels, but only the level shortcut goes through `log.info` — a channel catches both.
|
|
1216
|
+
|
|
1217
|
+
So capture through a channel as shown above; reach for a method spy only when you specifically want to assert "this exact shortcut was invoked".
|
|
1218
|
+
|
|
1219
|
+
## Testing a custom channel
|
|
1220
|
+
|
|
1221
|
+
Write specs against the channel directly; don't route through `Logger`:
|
|
1222
|
+
|
|
1223
|
+
```ts
|
|
1224
|
+
import { describe, it, expect, vi } from "vitest";
|
|
1225
|
+
import { SlackLog } from "./slack-log";
|
|
1226
|
+
|
|
1227
|
+
describe("SlackLog", () => {
|
|
1228
|
+
it("skips non-error levels", async () => {
|
|
1229
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response());
|
|
1230
|
+
|
|
1231
|
+
const channel = new SlackLog({ webhookUrl: "https://test", levels: ["error"] });
|
|
1232
|
+
await channel.log({ type: "info", module: "x", action: "y", message: "z" });
|
|
1233
|
+
|
|
1234
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
## Testing `FileLog` and `JSONFileLog`
|
|
1240
|
+
|
|
1241
|
+
Use real temp directories — it's the only way to exercise file IO, rotation, chunking, and JSON I/O with fidelity:
|
|
1242
|
+
|
|
1243
|
+
```ts
|
|
1244
|
+
import fs from "fs";
|
|
1245
|
+
import os from "os";
|
|
1246
|
+
import path from "path";
|
|
1247
|
+
import { randomUUID } from "node:crypto";
|
|
1248
|
+
|
|
1249
|
+
function tempDir() {
|
|
1250
|
+
const dir = path.join(os.tmpdir(), "logger-test", randomUUID());
|
|
1251
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1252
|
+
return dir;
|
|
1253
|
+
}
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
Clean up in `afterEach(() => fs.rmSync(dir, { recursive: true, force: true }))`.
|
|
1257
|
+
|
|
1258
|
+
## Waiting for async init
|
|
1259
|
+
|
|
1260
|
+
`LogChannel.init()` runs inside a `setTimeout(0)`. Before asserting on post-init behavior, yield once:
|
|
1261
|
+
|
|
1262
|
+
```ts
|
|
1263
|
+
const channel = new FileLog({ storagePath: tempDir() });
|
|
1264
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1265
|
+
// Now `channel.isInitialized` is true and it's safe to call `channel.log(...)` for real I/O.
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
## Testing `captureAnyUnhandledRejection`
|
|
1269
|
+
|
|
1270
|
+
Don't actually throw unhandled rejections in tests — emit the listener directly:
|
|
1271
|
+
|
|
1272
|
+
```ts
|
|
1273
|
+
captureAnyUnhandledRejection();
|
|
1274
|
+
process.emit("unhandledRejection", new Error("test"), Promise.resolve());
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
The `uncaughtException` path additionally calls `process.exit(1)`, so stub it (or pass `{ exitOnUncaughtException: false }`) before emitting — otherwise the emitted exception tears the test runner down:
|
|
1278
|
+
|
|
1279
|
+
```ts
|
|
1280
|
+
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
1281
|
+
captureAnyUnhandledRejection();
|
|
1282
|
+
process.emit("uncaughtException", new Error("test"), "uncaughtException");
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
See [`@warlock.js/logger/capture-unhandled-errors/SKILL.md`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) for a full example.
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
## use-log-helpers `@warlock.js/logger/use-log-helpers/SKILL.md`
|
|
1289
|
+
|
|
1290
|
+
---
|
|
1291
|
+
name: use-log-helpers
|
|
1292
|
+
description: 'Two DX shortcuts on every Logger — log.assert(condition, module, action, message, context?) logs an error when condition is falsy (free on the happy path), log.timer(module, action) returns an end-function emitting an info entry with measured duration. Triggers: `log.assert`, `log.timer`, `durationMs`; "assert an invariant via logger", "measure how long an operation took", "time a request", "log operation duration"; typical import `import { log } from "@warlock.js/logger"`. Skip: basics — `@warlock.js/logger/logger-basics/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing `console.assert`, `console.time`, `console.timeEnd`, `perf_hooks.performance.now`.'
|
|
1293
|
+
---
|
|
1294
|
+
|
|
1295
|
+
# Helpers — `assert`, `timer`
|
|
1296
|
+
|
|
1297
|
+
Two small DX shortcuts on every `Logger` (and the bound `log` helper). They route through the normal log pipeline — every channel sees what they emit.
|
|
1298
|
+
|
|
1299
|
+
## `log.assert(condition, module, action, message, context?)`
|
|
1300
|
+
|
|
1301
|
+
Logs an `error` entry when `condition` is falsy. Genuinely free in the happy path: when the condition is truthy, the entry is never built and channels are never invoked.
|
|
1302
|
+
|
|
1303
|
+
```ts
|
|
1304
|
+
log.assert(user !== null, "auth", "session", "user vanished mid-flight", {
|
|
1305
|
+
sessionId,
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
// truthy → no log call
|
|
1309
|
+
// falsy → equivalent to log.error("auth", "session", "user vanished...", { sessionId })
|
|
1310
|
+
```
|
|
1311
|
+
|
|
1312
|
+
The level is implicitly `error` — assertions express failures, not warnings. If you need a non-error level, use `log.error` / `log.warn` directly with your own `if`.
|
|
1313
|
+
|
|
1314
|
+
### Why not `console.assert`?
|
|
1315
|
+
|
|
1316
|
+
`console.assert` writes to stderr only and bypasses your file/JSON channels. `log.assert` runs through the logger pipeline, so a failed assertion is captured by every persistent channel you've configured. See [`@warlock.js/logger/pick-log-channel/SKILL.md`](@warlock.js/logger/pick-log-channel/SKILL.md).
|
|
1317
|
+
|
|
1318
|
+
## `log.timer(module, action)`
|
|
1319
|
+
|
|
1320
|
+
Returns an end-function. Calling it emits an `info` entry with `completed in <ms>ms` and a `durationMs` field in `context`.
|
|
1321
|
+
|
|
1322
|
+
```ts
|
|
1323
|
+
const end = log.timer("db", "users.findById");
|
|
1324
|
+
const user = await usersRepo.findById(id);
|
|
1325
|
+
end({ id, found: !!user });
|
|
1326
|
+
// ℹ info [db] [users.findById] completed in 12ms
|
|
1327
|
+
// ↳ { durationMs: 12, id: "abc", found: true } (when ConsoleLog has showContext: true)
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
Common patterns:
|
|
1331
|
+
|
|
1332
|
+
```ts
|
|
1333
|
+
// Around an HTTP handler
|
|
1334
|
+
async function handle(req) {
|
|
1335
|
+
const end = log.timer("http", `${req.method} ${req.url}`);
|
|
1336
|
+
try {
|
|
1337
|
+
return await runHandler(req);
|
|
1338
|
+
} finally {
|
|
1339
|
+
end({ status: res.statusCode });
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Around a job
|
|
1344
|
+
const end = log.timer("jobs", "nightly-report");
|
|
1345
|
+
await report.run();
|
|
1346
|
+
end({ rowsProcessed: report.rowCount });
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
`end()` can be called more than once if you want intermediate checkpoints — each call emits a fresh entry with the duration measured from the original `timer()` call.
|
|
1350
|
+
|
|
1351
|
+
### Caveats
|
|
1352
|
+
|
|
1353
|
+
- The duration is `Date.now()` based — millisecond resolution. For sub-millisecond profiling, reach for `performance.now()` directly.
|
|
1354
|
+
- The end-function captures `this` at construction; calling it after the logger is reconfigured still routes through the same `Logger` instance.
|
|
1355
|
+
- `log.timer` shorthand binds to the singleton — see [`@warlock.js/logger/test-logging-code/SKILL.md`](@warlock.js/logger/test-logging-code/SKILL.md) for how to swap channels per test.
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
## write-custom-log-channel `@warlock.js/logger/write-custom-log-channel/SKILL.md`
|
|
1359
|
+
|
|
1360
|
+
---
|
|
1361
|
+
name: write-custom-log-channel
|
|
1362
|
+
description: 'Extend the abstract LogChannel class for custom sinks — Slack, database, HTTP endpoint, in-memory buffer. Triggers: `LogChannel`, `LogContract`, `LoggingData`, `shouldBeLogged`, `init`, `flush`, `flushSync`, `terminal`; "log to slack", "log to a database", "send logs to datadog / loki HTTP api", "in-memory test capture channel", "build a custom log sink"; typical import `import { LogChannel, type LoggingData, type LogContract } from "@warlock.js/logger"`. Skip: built-in channels — `@warlock.js/logger/pick-log-channel/SKILL.md`; filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; competing libs `winston-transport`, `pino-transport`.'
|
|
1363
|
+
---
|
|
1364
|
+
|
|
1365
|
+
# Custom channels — extending `LogChannel`
|
|
1366
|
+
|
|
1367
|
+
Build a sink for any destination — Slack, a database, an HTTP endpoint — by extending the abstract `LogChannel` class.
|
|
1368
|
+
|
|
1369
|
+
## The 5-line minimum
|
|
1370
|
+
|
|
1371
|
+
```ts
|
|
1372
|
+
import { LogChannel, type LoggingData } from "@warlock.js/logger";
|
|
1373
|
+
|
|
1374
|
+
export class NullChannel extends LogChannel {
|
|
1375
|
+
public name = "null";
|
|
1376
|
+
public log(_data: LoggingData) {}
|
|
1377
|
+
}
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
Then:
|
|
1381
|
+
```ts
|
|
1382
|
+
log.addChannel(new NullChannel());
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
That's a working channel. `LogChannel` provides the scaffolding; you only need to supply `name` and `log()`.
|
|
1386
|
+
|
|
1387
|
+
## What `LogChannel` gives you
|
|
1388
|
+
|
|
1389
|
+
| Thing | Who provides it |
|
|
1390
|
+
|---|---|
|
|
1391
|
+
| `name`, `description`, `terminal` | You (fields on your subclass) |
|
|
1392
|
+
| `log(data)` | **You must implement** — abstract |
|
|
1393
|
+
| `flushSync()` | You (optional sync drain — only if you buffer) |
|
|
1394
|
+
| `flush()` | You (optional async drain — only if you buffer over async I/O) |
|
|
1395
|
+
| `init()` | You (optional async hook — see below) |
|
|
1396
|
+
| `shouldBeLogged(data)` | `LogChannel` — combines `levels` + `filter` |
|
|
1397
|
+
| `config<K>(key)` | `LogChannel` — merges user config with `defaultConfigurations` |
|
|
1398
|
+
| `getDateAndTimeFormat()` | `LogChannel` — returns resolved `dateFormat` |
|
|
1399
|
+
|
|
1400
|
+
## Complete example — SlackLog
|
|
1401
|
+
|
|
1402
|
+
```ts title="src/channels/slack-log.ts"
|
|
1403
|
+
import { LogChannel, type BasicLogConfigurations, type LoggingData } from "@warlock.js/logger";
|
|
1404
|
+
|
|
1405
|
+
// `LogChannel<Options>` constrains `Options extends BasicLogConfigurations`,
|
|
1406
|
+
// so extend the base to keep the inherited levels / filter / redact options.
|
|
1407
|
+
type SlackConfig = BasicLogConfigurations & {
|
|
1408
|
+
webhookUrl: string;
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
export class SlackLog extends LogChannel<SlackConfig> {
|
|
1412
|
+
public name = "slack";
|
|
1413
|
+
public description = "Posts errors + warnings to a Slack webhook";
|
|
1414
|
+
|
|
1415
|
+
public async log(data: LoggingData) {
|
|
1416
|
+
if (!this.shouldBeLogged(data)) return; // ← inherit levels + filter
|
|
1417
|
+
|
|
1418
|
+
await fetch(this.config("webhookUrl"), {
|
|
1419
|
+
method: "POST",
|
|
1420
|
+
headers: { "Content-Type": "application/json" },
|
|
1421
|
+
body: JSON.stringify({
|
|
1422
|
+
text: `[${data.type.toUpperCase()}] [${data.module}][${data.action}]: ${data.message}`,
|
|
1423
|
+
}),
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
Register it alongside built-ins:
|
|
1430
|
+
|
|
1431
|
+
```ts
|
|
1432
|
+
log.setChannels([
|
|
1433
|
+
new ConsoleLog(),
|
|
1434
|
+
new FileLog({ chunk: "daily" }),
|
|
1435
|
+
new SlackLog({
|
|
1436
|
+
webhookUrl: process.env.SLACK_WEBHOOK_URL!,
|
|
1437
|
+
levels: ["error", "warn"],
|
|
1438
|
+
}),
|
|
1439
|
+
]);
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
## The `init()` hook
|
|
1443
|
+
|
|
1444
|
+
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
|
+
|
|
1446
|
+
```ts
|
|
1447
|
+
export class DatabaseLog extends LogChannel<
|
|
1448
|
+
BasicLogConfigurations & { connectionString: string }
|
|
1449
|
+
> {
|
|
1450
|
+
public name = "database";
|
|
1451
|
+
private client!: SomeDbClient;
|
|
1452
|
+
|
|
1453
|
+
protected async init() {
|
|
1454
|
+
this.client = await SomeDbClient.connect(this.config("connectionString"));
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
public async log(data: LoggingData) {
|
|
1458
|
+
if (!this.shouldBeLogged(data)) return;
|
|
1459
|
+
await this.client.insert("logs", data);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
```
|
|
1463
|
+
|
|
1464
|
+
## Implementing `flushSync()`
|
|
1465
|
+
|
|
1466
|
+
Only if your channel buffers. Signature: `flushSync?(): void`. Synchronous — no `await`, no promises.
|
|
1467
|
+
|
|
1468
|
+
```ts
|
|
1469
|
+
export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: string }> {
|
|
1470
|
+
public name = "batch-http";
|
|
1471
|
+
private buffer: LoggingData[] = [];
|
|
1472
|
+
|
|
1473
|
+
public log(data: LoggingData) {
|
|
1474
|
+
if (!this.shouldBeLogged(data)) return;
|
|
1475
|
+
this.buffer.push(data);
|
|
1476
|
+
if (this.buffer.length >= 100) void this.drain();
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
public flushSync() {
|
|
1480
|
+
// Synchronous HTTP — use `node:http` or `XMLHttpRequest` polyfill.
|
|
1481
|
+
// If sync HTTP isn't possible, at least dump the buffer to disk here
|
|
1482
|
+
// so a follow-up async drain can recover it next boot.
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
private async drain() { /* async post to this.config("url") */ }
|
|
1486
|
+
}
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
## Implementing `flush()` — async drain
|
|
1490
|
+
|
|
1491
|
+
`flushSync()` is synchronous, so a channel that delivers over the network (or any async I/O) can't drain that way. Implement `flush(): Promise<void>` so `await log.flush()` drains it on a graceful shutdown:
|
|
1492
|
+
|
|
1493
|
+
```ts
|
|
1494
|
+
export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: string }> {
|
|
1495
|
+
public name = "batch-http";
|
|
1496
|
+
private buffer: LoggingData[] = [];
|
|
1497
|
+
|
|
1498
|
+
public log(data: LoggingData) {
|
|
1499
|
+
if (!this.shouldBeLogged(data)) return;
|
|
1500
|
+
this.buffer.push(data);
|
|
1501
|
+
if (this.buffer.length >= 100) void this.drain();
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
public async flush() {
|
|
1505
|
+
await this.drain(); // awaited by log.flush() on shutdown
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
private async drain() {
|
|
1509
|
+
if (this.buffer.length === 0) return;
|
|
1510
|
+
const batch = this.buffer.splice(0);
|
|
1511
|
+
await fetch(this.config("url"), { method: "POST", body: JSON.stringify(batch) });
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
`Logger.flush()` isolates each channel — a rejecting `flush()` won't break the others — but handle your own failures so a shutdown drain doesn't silently drop the batch. Implement `flushSync()` too (e.g. dump to disk) when you also want a best-effort sync path for `autoFlushOn`.
|
|
1517
|
+
|
|
1518
|
+
## The `terminal` property
|
|
1519
|
+
|
|
1520
|
+
- `terminal = true` (ConsoleLog default) → the logger passes the **original** message, ANSI codes intact.
|
|
1521
|
+
- `terminal = false` (base default, all file channels) → the logger passes a shallow-cloned copy whose `message` has ANSI codes stripped.
|
|
1522
|
+
|
|
1523
|
+
Set `terminal = true` on a channel only if its output is a TTY that should render colors.
|
|
1524
|
+
|
|
1525
|
+
## `LogContract` — the minimal interface
|
|
1526
|
+
|
|
1527
|
+
If you don't want anything `LogChannel` provides (level filtering, config merging), implement `LogContract` directly:
|
|
1528
|
+
|
|
1529
|
+
```ts
|
|
1530
|
+
import type { LogContract, LoggingData } from "@warlock.js/logger";
|
|
1531
|
+
|
|
1532
|
+
class MinimalSlack implements LogContract {
|
|
1533
|
+
public name = "slack";
|
|
1534
|
+
|
|
1535
|
+
public async log(data: LoggingData) {
|
|
1536
|
+
if (data.type !== "error") return;
|
|
1537
|
+
await fetch(process.env.SLACK_WEBHOOK!, { /* ... */ });
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
```
|
|
1541
|
+
|
|
1542
|
+
Prefer extending `LogChannel` unless you have a concrete reason not to — the level/filter plumbing is worth keeping.
|
|
1543
|
+
|
|
1544
|
+
## Don't do
|
|
1359
1545
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
# Custom channels — extending `LogChannel`
|
|
1366
|
-
|
|
1367
|
-
Build a sink for any destination — Slack, a database, an HTTP endpoint — by extending the abstract `LogChannel` class.
|
|
1368
|
-
|
|
1369
|
-
## The 5-line minimum
|
|
1370
|
-
|
|
1371
|
-
```ts
|
|
1372
|
-
import { LogChannel, type LoggingData } from "@warlock.js/logger";
|
|
1373
|
-
|
|
1374
|
-
export class NullChannel extends LogChannel {
|
|
1375
|
-
public name = "null";
|
|
1376
|
-
public log(_data: LoggingData) {}
|
|
1377
|
-
}
|
|
1378
|
-
```
|
|
1379
|
-
|
|
1380
|
-
Then:
|
|
1381
|
-
```ts
|
|
1382
|
-
log.addChannel(new NullChannel());
|
|
1383
|
-
```
|
|
1384
|
-
|
|
1385
|
-
That's a working channel. `LogChannel` provides the scaffolding; you only need to supply `name` and `log()`.
|
|
1386
|
-
|
|
1387
|
-
## What `LogChannel` gives you
|
|
1388
|
-
|
|
1389
|
-
| Thing | Who provides it |
|
|
1390
|
-
|---|---|
|
|
1391
|
-
| `name`, `description`, `terminal` | You (fields on your subclass) |
|
|
1392
|
-
| `log(data)` | **You must implement** — abstract |
|
|
1393
|
-
| `flushSync()` | You (optional sync drain — only if you buffer) |
|
|
1394
|
-
| `flush()` | You (optional async drain — only if you buffer over async I/O) |
|
|
1395
|
-
| `init()` | You (optional async hook — see below) |
|
|
1396
|
-
| `shouldBeLogged(data)` | `LogChannel` — combines `levels` + `filter` |
|
|
1397
|
-
| `config<K>(key)` | `LogChannel` — merges user config with `defaultConfigurations` |
|
|
1398
|
-
| `getDateAndTimeFormat()` | `LogChannel` — returns resolved `dateFormat` |
|
|
1399
|
-
|
|
1400
|
-
## Complete example — SlackLog
|
|
1401
|
-
|
|
1402
|
-
```ts title="src/channels/slack-log.ts"
|
|
1403
|
-
import { LogChannel, type BasicLogConfigurations, type LoggingData } from "@warlock.js/logger";
|
|
1404
|
-
|
|
1405
|
-
// `LogChannel<Options>` constrains `Options extends BasicLogConfigurations`,
|
|
1406
|
-
// so extend the base to keep the inherited levels / filter / redact options.
|
|
1407
|
-
type SlackConfig = BasicLogConfigurations & {
|
|
1408
|
-
webhookUrl: string;
|
|
1409
|
-
};
|
|
1410
|
-
|
|
1411
|
-
export class SlackLog extends LogChannel<SlackConfig> {
|
|
1412
|
-
public name = "slack";
|
|
1413
|
-
public description = "Posts errors + warnings to a Slack webhook";
|
|
1414
|
-
|
|
1415
|
-
public async log(data: LoggingData) {
|
|
1416
|
-
if (!this.shouldBeLogged(data)) return; // ← inherit levels + filter
|
|
1417
|
-
|
|
1418
|
-
await fetch(this.config("webhookUrl"), {
|
|
1419
|
-
method: "POST",
|
|
1420
|
-
headers: { "Content-Type": "application/json" },
|
|
1421
|
-
body: JSON.stringify({
|
|
1422
|
-
text: `[${data.type.toUpperCase()}] [${data.module}][${data.action}]: ${data.message}`,
|
|
1423
|
-
}),
|
|
1424
|
-
});
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
```
|
|
1428
|
-
|
|
1429
|
-
Register it alongside built-ins:
|
|
1430
|
-
|
|
1431
|
-
```ts
|
|
1432
|
-
log.setChannels([
|
|
1433
|
-
new ConsoleLog(),
|
|
1434
|
-
new FileLog({ chunk: "daily" }),
|
|
1435
|
-
new SlackLog({
|
|
1436
|
-
webhookUrl: process.env.SLACK_WEBHOOK_URL!,
|
|
1437
|
-
levels: ["error", "warn"],
|
|
1438
|
-
}),
|
|
1439
|
-
]);
|
|
1440
|
-
```
|
|
1441
|
-
|
|
1442
|
-
## The `init()` hook
|
|
1443
|
-
|
|
1444
|
-
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
|
-
|
|
1446
|
-
```ts
|
|
1447
|
-
export class DatabaseLog extends LogChannel<
|
|
1448
|
-
BasicLogConfigurations & { connectionString: string }
|
|
1449
|
-
> {
|
|
1450
|
-
public name = "database";
|
|
1451
|
-
private client!: SomeDbClient;
|
|
1452
|
-
|
|
1453
|
-
protected async init() {
|
|
1454
|
-
this.client = await SomeDbClient.connect(this.config("connectionString"));
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
public async log(data: LoggingData) {
|
|
1458
|
-
if (!this.shouldBeLogged(data)) return;
|
|
1459
|
-
await this.client.insert("logs", data);
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
```
|
|
1463
|
-
|
|
1464
|
-
## Implementing `flushSync()`
|
|
1465
|
-
|
|
1466
|
-
Only if your channel buffers. Signature: `flushSync?(): void`. Synchronous — no `await`, no promises.
|
|
1467
|
-
|
|
1468
|
-
```ts
|
|
1469
|
-
export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: string }> {
|
|
1470
|
-
public name = "batch-http";
|
|
1471
|
-
private buffer: LoggingData[] = [];
|
|
1472
|
-
|
|
1473
|
-
public log(data: LoggingData) {
|
|
1474
|
-
if (!this.shouldBeLogged(data)) return;
|
|
1475
|
-
this.buffer.push(data);
|
|
1476
|
-
if (this.buffer.length >= 100) void this.drain();
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
public flushSync() {
|
|
1480
|
-
// Synchronous HTTP — use `node:http` or `XMLHttpRequest` polyfill.
|
|
1481
|
-
// If sync HTTP isn't possible, at least dump the buffer to disk here
|
|
1482
|
-
// so a follow-up async drain can recover it next boot.
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
private async drain() { /* async post to this.config("url") */ }
|
|
1486
|
-
}
|
|
1487
|
-
```
|
|
1488
|
-
|
|
1489
|
-
## Implementing `flush()` — async drain
|
|
1490
|
-
|
|
1491
|
-
`flushSync()` is synchronous, so a channel that delivers over the network (or any async I/O) can't drain that way. Implement `flush(): Promise<void>` so `await log.flush()` drains it on a graceful shutdown:
|
|
1492
|
-
|
|
1493
|
-
```ts
|
|
1494
|
-
export class BatchHttpLog extends LogChannel<BasicLogConfigurations & { url: string }> {
|
|
1495
|
-
public name = "batch-http";
|
|
1496
|
-
private buffer: LoggingData[] = [];
|
|
1497
|
-
|
|
1498
|
-
public log(data: LoggingData) {
|
|
1499
|
-
if (!this.shouldBeLogged(data)) return;
|
|
1500
|
-
this.buffer.push(data);
|
|
1501
|
-
if (this.buffer.length >= 100) void this.drain();
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
public async flush() {
|
|
1505
|
-
await this.drain(); // awaited by log.flush() on shutdown
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
private async drain() {
|
|
1509
|
-
if (this.buffer.length === 0) return;
|
|
1510
|
-
const batch = this.buffer.splice(0);
|
|
1511
|
-
await fetch(this.config("url"), { method: "POST", body: JSON.stringify(batch) });
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
```
|
|
1515
|
-
|
|
1516
|
-
`Logger.flush()` isolates each channel — a rejecting `flush()` won't break the others — but handle your own failures so a shutdown drain doesn't silently drop the batch. Implement `flushSync()` too (e.g. dump to disk) when you also want a best-effort sync path for `autoFlushOn`.
|
|
1517
|
-
|
|
1518
|
-
## The `terminal` property
|
|
1519
|
-
|
|
1520
|
-
- `terminal = true` (ConsoleLog default) → the logger passes the **original** message, ANSI codes intact.
|
|
1521
|
-
- `terminal = false` (base default, all file channels) → the logger passes a shallow-cloned copy whose `message` has ANSI codes stripped.
|
|
1522
|
-
|
|
1523
|
-
Set `terminal = true` on a channel only if its output is a TTY that should render colors.
|
|
1524
|
-
|
|
1525
|
-
## `LogContract` — the minimal interface
|
|
1526
|
-
|
|
1527
|
-
If you don't want anything `LogChannel` provides (level filtering, config merging), implement `LogContract` directly:
|
|
1528
|
-
|
|
1529
|
-
```ts
|
|
1530
|
-
import type { LogContract, LoggingData } from "@warlock.js/logger";
|
|
1531
|
-
|
|
1532
|
-
class MinimalSlack implements LogContract {
|
|
1533
|
-
public name = "slack";
|
|
1534
|
-
|
|
1535
|
-
public async log(data: LoggingData) {
|
|
1536
|
-
if (data.type !== "error") return;
|
|
1537
|
-
await fetch(process.env.SLACK_WEBHOOK!, { /* ... */ });
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
```
|
|
1541
|
-
|
|
1542
|
-
Prefer extending `LogChannel` unless you have a concrete reason not to — the level/filter plumbing is worth keeping.
|
|
1543
|
-
|
|
1544
|
-
## Don't do
|
|
1545
|
-
|
|
1546
|
-
- Don't mutate `data` inside `log()`. Later channels see the mutation if the logger passes the same reference.
|
|
1547
|
-
- Don't throw synchronously from `log()`. The logger fires it without awaiting; an unhandled rejection takes down the process (unless `captureAnyUnhandledRejection` is wired up — and then it's embarrassing to be the cause).
|
|
1548
|
-
- Don't block the event loop. `log()` may be sync or async; if your work takes >100ms, make it async and return the promise.
|
|
1549
|
-
- Don't forget `shouldBeLogged(data)` at the top of `log()` — or your channel silently ignores `levels` / `filter` config.
|
|
1546
|
+
- Don't mutate `data` inside `log()`. Later channels see the mutation if the logger passes the same reference.
|
|
1547
|
+
- Don't throw synchronously from `log()`. The logger fires it without awaiting; an unhandled rejection takes down the process (unless `captureAnyUnhandledRejection` is wired up — and then it's embarrassing to be the cause).
|
|
1548
|
+
- Don't block the event loop. `log()` may be sync or async; if your work takes >100ms, make it async and return the promise.
|
|
1549
|
+
- Don't forget `shouldBeLogged(data)` at the top of `log()` — or your channel silently ignores `levels` / `filter` config.
|
|
1550
1550
|
|
|
1551
1551
|
|