@warlock.js/logger 4.0.174 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +145 -422
  2. package/cjs/index.cjs +1003 -0
  3. package/cjs/index.cjs.map +1 -0
  4. package/esm/channels/console-log.d.mts +40 -0
  5. package/esm/channels/console-log.d.mts.map +1 -0
  6. package/esm/channels/console-log.mjs +51 -0
  7. package/esm/channels/console-log.mjs.map +1 -0
  8. package/esm/channels/file-log.d.mts +194 -0
  9. package/esm/channels/file-log.d.mts.map +1 -0
  10. package/esm/channels/file-log.mjs +267 -0
  11. package/esm/channels/file-log.mjs.map +1 -0
  12. package/esm/channels/index.mjs +5 -0
  13. package/esm/channels/json-file-log.d.mts +33 -0
  14. package/esm/channels/json-file-log.d.mts.map +1 -0
  15. package/esm/channels/json-file-log.mjs +137 -0
  16. package/esm/channels/json-file-log.mjs.map +1 -0
  17. package/esm/index.d.mts +11 -0
  18. package/esm/index.mjs +13 -0
  19. package/esm/log-channel.d.mts +78 -0
  20. package/esm/log-channel.d.mts.map +1 -0
  21. package/esm/log-channel.mjs +75 -0
  22. package/esm/log-channel.mjs.map +1 -0
  23. package/esm/logger.d.mts +184 -0
  24. package/esm/logger.d.mts.map +1 -0
  25. package/esm/logger.mjs +282 -0
  26. package/esm/logger.mjs.map +1 -0
  27. package/esm/redact/redact.d.mts +25 -0
  28. package/esm/redact/redact.d.mts.map +1 -0
  29. package/esm/redact/redact.mjs +109 -0
  30. package/esm/redact/redact.mjs.map +1 -0
  31. package/esm/types.d.mts +129 -0
  32. package/esm/types.d.mts.map +1 -0
  33. package/esm/utils/capture-unhandled-errors.d.mts +16 -0
  34. package/esm/utils/capture-unhandled-errors.d.mts.map +1 -0
  35. package/esm/utils/capture-unhandled-errors.mjs +26 -0
  36. package/esm/utils/capture-unhandled-errors.mjs.map +1 -0
  37. package/esm/utils/clear-message.d.mts +8 -0
  38. package/esm/utils/clear-message.d.mts.map +1 -0
  39. package/esm/utils/clear-message.mjs +12 -0
  40. package/esm/utils/clear-message.mjs.map +1 -0
  41. package/esm/utils/index.mjs +5 -0
  42. package/esm/utils/safe-json-stringify.d.mts +14 -0
  43. package/esm/utils/safe-json-stringify.d.mts.map +1 -0
  44. package/esm/utils/safe-json-stringify.mjs +35 -0
  45. package/esm/utils/safe-json-stringify.mjs.map +1 -0
  46. package/llms-full.txt +1296 -0
  47. package/llms.txt +19 -0
  48. package/package.json +39 -39
  49. package/skills/capture-unhandled-errors/SKILL.md +103 -0
  50. package/skills/configure-logger/SKILL.md +105 -0
  51. package/skills/filter-log-entries/SKILL.md +120 -0
  52. package/skills/flush-logs-on-shutdown/SKILL.md +91 -0
  53. package/skills/logger-basics/SKILL.md +85 -0
  54. package/skills/overview/SKILL.md +86 -0
  55. package/skills/pick-log-channel/SKILL.md +139 -0
  56. package/skills/redact-sensitive-log-fields/SKILL.md +122 -0
  57. package/skills/test-logging-code/SKILL.md +169 -0
  58. package/skills/use-log-helpers/SKILL.md +66 -0
  59. package/skills/write-custom-log-channel/SKILL.md +160 -0
  60. package/cjs/channels/console-log.d.ts +0 -17
  61. package/cjs/channels/console-log.d.ts.map +0 -1
  62. package/cjs/channels/console-log.js +0 -47
  63. package/cjs/channels/console-log.js.map +0 -1
  64. package/cjs/channels/file-log.d.ts +0 -171
  65. package/cjs/channels/file-log.d.ts.map +0 -1
  66. package/cjs/channels/file-log.js +0 -293
  67. package/cjs/channels/file-log.js.map +0 -1
  68. package/cjs/channels/index.d.ts +0 -4
  69. package/cjs/channels/index.d.ts.map +0 -1
  70. package/cjs/channels/json-file-log.d.ts +0 -33
  71. package/cjs/channels/json-file-log.d.ts.map +0 -1
  72. package/cjs/channels/json-file-log.js +0 -164
  73. package/cjs/channels/json-file-log.js.map +0 -1
  74. package/cjs/index.d.ts +0 -6
  75. package/cjs/index.d.ts.map +0 -1
  76. package/cjs/index.js +0 -1
  77. package/cjs/index.js.map +0 -1
  78. package/cjs/log-channel.d.ts +0 -67
  79. package/cjs/log-channel.d.ts.map +0 -1
  80. package/cjs/log-channel.js +0 -88
  81. package/cjs/log-channel.js.map +0 -1
  82. package/cjs/logger.d.ts +0 -62
  83. package/cjs/logger.d.ts.map +0 -1
  84. package/cjs/logger.js +0 -124
  85. package/cjs/logger.js.map +0 -1
  86. package/cjs/types.d.ts +0 -104
  87. package/cjs/types.d.ts.map +0 -1
  88. package/cjs/utils/capture-unhandled-errors.d.ts +0 -2
  89. package/cjs/utils/capture-unhandled-errors.d.ts.map +0 -1
  90. package/cjs/utils/capture-unhandled-errors.js +0 -12
  91. package/cjs/utils/capture-unhandled-errors.js.map +0 -1
  92. package/cjs/utils/clear-message.d.ts +0 -5
  93. package/cjs/utils/clear-message.d.ts.map +0 -1
  94. package/cjs/utils/clear-message.js +0 -9
  95. package/cjs/utils/clear-message.js.map +0 -1
  96. package/cjs/utils/index.d.ts +0 -3
  97. package/cjs/utils/index.d.ts.map +0 -1
  98. package/esm/channels/console-log.d.ts +0 -17
  99. package/esm/channels/console-log.d.ts.map +0 -1
  100. package/esm/channels/console-log.js +0 -47
  101. package/esm/channels/console-log.js.map +0 -1
  102. package/esm/channels/file-log.d.ts +0 -171
  103. package/esm/channels/file-log.d.ts.map +0 -1
  104. package/esm/channels/file-log.js +0 -293
  105. package/esm/channels/file-log.js.map +0 -1
  106. package/esm/channels/index.d.ts +0 -4
  107. package/esm/channels/index.d.ts.map +0 -1
  108. package/esm/channels/json-file-log.d.ts +0 -33
  109. package/esm/channels/json-file-log.d.ts.map +0 -1
  110. package/esm/channels/json-file-log.js +0 -164
  111. package/esm/channels/json-file-log.js.map +0 -1
  112. package/esm/index.d.ts +0 -6
  113. package/esm/index.d.ts.map +0 -1
  114. package/esm/index.js +0 -1
  115. package/esm/index.js.map +0 -1
  116. package/esm/log-channel.d.ts +0 -67
  117. package/esm/log-channel.d.ts.map +0 -1
  118. package/esm/log-channel.js +0 -88
  119. package/esm/log-channel.js.map +0 -1
  120. package/esm/logger.d.ts +0 -62
  121. package/esm/logger.d.ts.map +0 -1
  122. package/esm/logger.js +0 -124
  123. package/esm/logger.js.map +0 -1
  124. package/esm/types.d.ts +0 -104
  125. package/esm/types.d.ts.map +0 -1
  126. package/esm/utils/capture-unhandled-errors.d.ts +0 -2
  127. package/esm/utils/capture-unhandled-errors.d.ts.map +0 -1
  128. package/esm/utils/capture-unhandled-errors.js +0 -12
  129. package/esm/utils/capture-unhandled-errors.js.map +0 -1
  130. package/esm/utils/clear-message.d.ts +0 -5
  131. package/esm/utils/clear-message.d.ts.map +0 -1
  132. package/esm/utils/clear-message.js +0 -9
  133. package/esm/utils/clear-message.js.map +0 -1
  134. package/esm/utils/index.d.ts +0 -3
  135. package/esm/utils/index.d.ts.map +0 -1
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: overview
3
+ description: 'Front-door orientation for `@warlock.js/logger` — structured channel-based logging with five severity levels, PII redaction floor, buffered file/JSON channels, signal-flush on shutdown, ergonomic helpers (timer, assert). Standalone — no `@warlock.js/core` required. TRIGGER when: code imports anything from `@warlock.js/logger`; user asks "what does @warlock.js/logger do", "compare with pino / winston / bunyan", "structured logging for Node", "which logger should I use", "how do channels work"; package.json adds `@warlock.js/logger`. Skip: specific task already known — load the matching task skill directly (`logger-basics`, `configure-logger`, `pick-log-channel`, `write-custom-log-channel`, `redact-sensitive-log-fields`, `filter-log-entries`, `flush-logs-on-shutdown`, `capture-unhandled-errors`, `use-log-helpers`, `test-logging-code`); plain `console.log` in throwaway scripts.'
4
+ ---
5
+
6
+ # `@warlock.js/logger` — overview
7
+
8
+ Structured logging for Node. Five severity levels, 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, and a couple of ergonomic helpers (`timer`, `assert`) that turn boilerplate into one-liners.
9
+
10
+ Ships standalone — `@warlock.js/core` is not required. Drop it into any Node project.
11
+
12
+ ## When to reach for it
13
+
14
+ - 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.
15
+ - 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).
16
+ - 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.
17
+
18
+ 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.
19
+
20
+ ## The mental model in one paragraph
21
+
22
+ You write `log.info("auth", "login", "user signed in", { userId })`. The logger fans that single entry out to every registered channel (`ConsoleLog`, `FileLog`, `JSONFileLog`, 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, either manually (`log.flushSync()`) or automatically via signal handlers (`enableAutoFlush(['SIGINT', 'SIGTERM', 'beforeExit'])`). That's the whole package.
23
+
24
+ ## Skills index
25
+
26
+ Ten task skills cover everything. Load the one that matches your job — most callers only ever need `logger-basics` + `configure-logger` + `pick-log-channel`.
27
+
28
+ ### Foundations
29
+
30
+ #### [`logger-basics`](@warlock.js/logger/logger-basics/SKILL.md)
31
+ Start here. The `log` singleton, the five levels (`debug` / `info` / `warn` / `error` / `success`), how fan-out works, the `module / action / message / context` shape every entry carries.
32
+
33
+ #### [`configure-logger`](@warlock.js/logger/configure-logger/SKILL.md)
34
+ 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.
35
+
36
+ ### Channels
37
+
38
+ #### [`pick-log-channel`](@warlock.js/logger/pick-log-channel/SKILL.md)
39
+ Pick one of the three built-ins: `ConsoleLog` (terminal, colored), `FileLog` (plain `.log` on disk with rotation), `JSONFileLog` (structured JSON for aggregators — Datadog, Loki, ELK).
40
+
41
+ #### [`write-custom-log-channel`](@warlock.js/logger/write-custom-log-channel/SKILL.md)
42
+ Extend `LogChannel<Options>` for sinks the built-ins don't cover — Slack, HTTP endpoint, in-memory buffer, database. The lazy `init()` lifecycle (`setTimeout(0)`) and the `terminal: true/false` ANSI-stripping behavior are subtle — read this skill before subclassing.
43
+
44
+ ### Production concerns
45
+
46
+ #### [`redact-sensitive-log-fields`](@warlock.js/logger/redact-sensitive-log-fields/SKILL.md)
47
+ 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`.
48
+
49
+ #### [`filter-log-entries`](@warlock.js/logger/filter-log-entries/SKILL.md)
50
+ Drop entries before they cost anything. Logger-wide `setMinLevel("info")` is the fast path; per-channel `levels` array + `filter` predicate for fine control.
51
+
52
+ #### [`flush-logs-on-shutdown`](@warlock.js/logger/flush-logs-on-shutdown/SKILL.md)
53
+ Buffered channels (file + JSON file) need explicit drain. `log.flushSync()` manually, or `enableAutoFlush(['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK', 'SIGUSR2', 'beforeExit'])` to wire process signals. Signals are re-raised after flush so Node's default exit still runs.
54
+
55
+ #### [`capture-unhandled-errors`](@warlock.js/logger/capture-unhandled-errors/SKILL.md)
56
+ `captureAnyUnhandledRejection()` hooks `unhandledRejection` + `uncaughtException` and routes both through `log.error("app", ...)`. One call at startup, pair with `autoFlushOn: ['beforeExit']` to land the entry on disk.
57
+
58
+ ### Ergonomics + testing
59
+
60
+ #### [`use-log-helpers`](@warlock.js/logger/use-log-helpers/SKILL.md)
61
+ 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`.
62
+
63
+ #### [`test-logging-code`](@warlock.js/logger/test-logging-code/SKILL.md)
64
+ 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`.
65
+
66
+ ## Built-in channels at a glance
67
+
68
+ | Channel | Sink | `terminal` | Buffered |
69
+ | --- | --- | --- | --- |
70
+ | `ConsoleLog` | `process.stdout` | `true` (colors kept) | no |
71
+ | `FileLog` | `.log` files | `false` (ANSI stripped) | yes (5s timer or 100-entry buffer) |
72
+ | `JSONFileLog` | `.json` files | `false` (ANSI stripped) | yes (same buffering) |
73
+
74
+ `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.
75
+
76
+ ## What this package deliberately doesn't do
77
+
78
+ - **Distributed tracing.** Use OpenTelemetry. Logger gets you structured local logs with `module / action`; trace correlation is a different problem.
79
+ - **Log shipping.** Write a custom channel that POSTs to your aggregator, or use `JSONFileLog` + a sidecar (fluentbit, vector, promtail). The package doesn't bundle network sinks.
80
+ - **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.
81
+ - **Log analysis.** Querying / aggregating / alerting is on the sink side (Loki, ELK, Datadog).
82
+
83
+ ## See also
84
+
85
+ - [`@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.
86
+ - 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.
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: pick-log-channel
3
+ description: 'Pick one of the three built-in channels — ConsoleLog (terminal), FileLog (plain text on disk), JSONFileLog (structured JSON for aggregators like Loki / Datadog / Elastic). Triggers: `ConsoleLog`, `FileLog`, `JSONFileLog`, `chunk`, `rotate`, `groupBy`, `maxFileSize`, `showContext`, `log.channel`; "log to a file", "rotate log files", "daily log chunks", "json logs for datadog / loki / elastic"; typical import `import { ConsoleLog, FileLog, JSONFileLog } from "@warlock.js/logger"`. Skip: 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`.'
4
+ ---
5
+
6
+ # Channels — which one to pick and how to configure it
7
+
8
+ Three built-in channels. A channel is a destination for a log entry — the logger fans out every entry to every registered channel in parallel.
9
+
10
+ ## The decision
11
+
12
+ | Need | Pick |
13
+ |---|---|
14
+ | Local dev, colored output in the terminal | `ConsoleLog` |
15
+ | Plain text `.log` files on disk — humans read them | `FileLog` |
16
+ | Structured `.json` files — a log aggregator (Loki / Datadog / Elastic) reads them | `JSONFileLog` |
17
+
18
+ Most production setups use **two** channels: `ConsoleLog` + one file channel. Dev uses `ConsoleLog` only.
19
+
20
+ ## `ConsoleLog`
21
+
22
+ Zero config. Colored, icon-prefixed lines to the terminal.
23
+
24
+ ```ts
25
+ import { ConsoleLog } from "@warlock.js/logger";
26
+
27
+ new ConsoleLog();
28
+ // ⚙ (2024-03-15T10:22:00.000Z) [auth] [hashPassword] Hashing started
29
+ // ℹ (2024-03-15T10:22:01.482Z) [users] [register] New user created
30
+ // ✗ (2024-03-15T10:22:03.111Z) [payments] [charge] Card declined
31
+ ```
32
+
33
+ Properties:
34
+ - `name = "console"`, `terminal = true`
35
+ - Accepts `ConsoleLogConfig` — `levels`, `filter`, `dateFormat`, `showContext`, `contextDepth`
36
+ - If `message` is an object, a second `console.log(message)` is issued so Node's inspector can expand it
37
+
38
+ ### Showing context
39
+
40
+ 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:
41
+
42
+ ```ts
43
+ new ConsoleLog({ showContext: true });
44
+
45
+ log.info("payments", "charge", "card declined", { userId: 42, amount: 1999 });
46
+ // ℹ (…) [payments] [charge] card declined
47
+ // ↳ { userId: 42, amount: 1999 }
48
+ ```
49
+
50
+ Tune `contextDepth` (default `4`) to clamp how deep `util.inspect` recurses into nested objects.
51
+
52
+ ## `FileLog`
53
+
54
+ Plain text. Buffers in memory, flushes to disk periodically.
55
+
56
+ ```ts
57
+ import { FileLog } from "@warlock.js/logger";
58
+
59
+ new FileLog({
60
+ storagePath: "./storage/logs", // default: process.cwd() + "/storage/logs"
61
+ name: "app", // default: "app"
62
+ extension: "log", // default: "log"
63
+ chunk: "daily", // "single" (default) | "daily" | "hourly"
64
+ rotate: true, // default: true
65
+ maxFileSize: 10 * 1024 * 1024, // default: 10MB — triggers rotation
66
+ maxMessagesToWrite: 100, // default: 100 — flush threshold
67
+ groupBy: ["level", "module"], // optional subdirectory nesting
68
+ });
69
+ ```
70
+
71
+ Line format: `[date time] [level] [module][action]: message` — or a `[trace]` block when `message` is an `Error`.
72
+
73
+ ### Key gotchas
74
+
75
+ - **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.
76
+ - **`chunk: "daily"` picks a filename per day.** File name becomes `DD-MM-YYYY.log`. Combined with `rotate: true`, rotated archives get `Date.now()` suffixed.
77
+ - **`groupBy` nests directories.** `groupBy: ["level", "module"]` produces `storage/logs/error/payments/app.log`. Order matters.
78
+ - **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.)
79
+
80
+ ## `JSONFileLog`
81
+
82
+ Subclass of `FileLog` — same buffering, chunking, rotation, grouping. Output is a JSON object with a `messages` array:
83
+
84
+ ```json
85
+ {
86
+ "messages": [
87
+ {
88
+ "content": "Card declined",
89
+ "level": "error",
90
+ "date": "15-03-2024 10:22:03",
91
+ "module": "payments",
92
+ "action": "charge",
93
+ "stack": [
94
+ "Error: Card declined",
95
+ " at chargeCard (/app/src/payments.ts:42:11)"
96
+ ]
97
+ }
98
+ ]
99
+ }
100
+ ```
101
+
102
+ Differences from `FileLog`:
103
+ - `name = "fileJson"` (**not** `"json"` — use this exact string for `log.channel("fileJson")`)
104
+ - `extension` is always `"json"` — the option is silently ignored
105
+ - Error `stack` is stored as `string[]` (split on newlines) — easy to query in aggregators
106
+ - `content` holds the original user-supplied `message` (not a pre-formatted line)
107
+ - Corrupted existing file → reinitialized to `{ messages: [] }` on next write (does not throw)
108
+ - **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.
109
+
110
+ ## Shared config — `BasicLogConfigurations`
111
+
112
+ Every channel constructor accepts at minimum:
113
+
114
+ ```ts
115
+ type BasicLogConfigurations = {
116
+ levels?: LogLevel[]; // whitelist — omit or [] to allow all
117
+ filter?: (data: LoggingData) => boolean; // custom predicate
118
+ dateFormat?: { date?: string; time?: string }; // Day.js format strings
119
+ context?: (data) => Promise<Record<string, any>>; // reserved — not yet read
120
+ };
121
+ ```
122
+
123
+ Concrete file channels extend this with their storage/chunk/rotate/groupBy options via intersection.
124
+
125
+ ## Picking a channel by name at runtime
126
+
127
+ ```ts
128
+ log.channel("console"); // → ConsoleLog | undefined
129
+ log.channel("file"); // → FileLog | undefined
130
+ log.channel("fileJson"); // ← note the name — NOT "json"
131
+ ```
132
+
133
+ If two channels share a `name`, only one is reachable this way — the search returns the first match.
134
+
135
+ ## See also
136
+
137
+ - [`@warlock.js/logger/configure-logger/SKILL.md`](@warlock.js/logger/configure-logger/SKILL.md) — registering channels at startup
138
+ - [`@warlock.js/logger/filter-log-entries/SKILL.md`](@warlock.js/logger/filter-log-entries/SKILL.md) — `levels` and `filter` config in detail
139
+ - [`@warlock.js/logger/write-custom-log-channel/SKILL.md`](@warlock.js/logger/write-custom-log-channel/SKILL.md) — extending `LogChannel` for custom sinks
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: redact-sensitive-log-fields
3
+ description: 'Strip secrets from log output — two-layer additive redaction via log.configure({redact: {paths}}) (logger floor) + per-channel redact (more paths on top). Dotted glob paths (*, **). Triggers: `redact`, `paths`, `censor`, `log.setRedact`, `applyRedact`; "redact passwords in logs", "strip tokens from log output", "hide authorization headers", "scrub PII before logging"; typical import `import { log } from "@warlock.js/logger"`. Skip: filtering — `@warlock.js/logger/filter-log-entries/SKILL.md`; custom sinks — `@warlock.js/logger/write-custom-log-channel/SKILL.md`; competing libs `pino.redact`, `fast-redact`.'
4
+ ---
5
+
6
+ # Redaction — keeping secrets out of logs
7
+
8
+ Two layers, both opt-in. Configured at the logger and/or per channel.
9
+
10
+ ## The model in one line
11
+
12
+ > Logger-wide redaction is the security floor. Per-channel redaction adds more paths. **No channel can ever undo a logger-wide redaction.**
13
+
14
+ 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.
15
+
16
+ ## Logger-wide floor
17
+
18
+ ```ts
19
+ import { log } from "@warlock.js/logger";
20
+
21
+ log.configure({
22
+ redact: {
23
+ paths: [
24
+ "context.password",
25
+ "context.*.token",
26
+ "context.headers.authorization",
27
+ ],
28
+ censor: "[REDACTED]", // default — string or function
29
+ },
30
+ });
31
+
32
+ // runtime equivalent:
33
+ log.setRedact({ paths: ["context.password"] });
34
+ log.setRedact(undefined); // clear
35
+ ```
36
+
37
+ Every channel sees the redacted entry. Cheap: applied **once** before fan-out; channels share the redacted clone unless they add their own paths.
38
+
39
+ ## Per-channel additive
40
+
41
+ ```ts
42
+ new SlackChannel({
43
+ webhook: "...",
44
+ redact: {
45
+ paths: ["context.user.email", "context.metadata.*"],
46
+ // censor inherited from logger-wide when omitted
47
+ },
48
+ });
49
+ ```
50
+
51
+ 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.
52
+
53
+ ### When to set redact per-channel
54
+
55
+ - Loud destinations with broader audiences (Slack, Discord, error trackers, anything off your machine) — redact more aggressively.
56
+ - Local-only destinations (FileLog you alone read, the dev terminal) — keep the floor minimal so you can debug.
57
+
58
+ ### When NOT to set it
59
+
60
+ 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.
61
+
62
+ ## Path syntax
63
+
64
+ Paths are dotted glob patterns evaluated against the full `LoggingData`:
65
+
66
+ ```
67
+ type LoggingData = {
68
+ type: "info" | ...,
69
+ module: string,
70
+ action: string,
71
+ message: any, // ← prefix paths with "message." to redact here
72
+ context?: object, // ← prefix paths with "context." to redact here
73
+ };
74
+ ```
75
+
76
+ | Pattern | Matches |
77
+ | --- | --- |
78
+ | `context.password` | exactly `data.context.password` |
79
+ | `context.*.token` | `data.context.<any>.token` (one segment in between) |
80
+ | `**.password` | `data.context.password`, `data.context.user.password`, … any depth |
81
+ | `message.apiKey` | when message is an object, `data.message.apiKey` |
82
+ | `context.users.*.token` | array element redaction (`*` matches indices too) |
83
+
84
+ Wildcards:
85
+
86
+ - `*` — exactly one segment (any object key, any array index).
87
+ - `**` — zero or more segments, greedily; matches at any depth.
88
+
89
+ ## Censor variants
90
+
91
+ ```ts
92
+ // String — replace with a literal.
93
+ { censor: "[REDACTED]" }
94
+ { censor: "***" }
95
+
96
+ // Function — receives original value + dotted path, returns the replacement.
97
+ {
98
+ censor: (value, path) => {
99
+ if (typeof value !== "string") return "[REDACTED]";
100
+ return value.length > 4 ? `${value.slice(0, 2)}***${value.slice(-2)}` : "***";
101
+ },
102
+ }
103
+ ```
104
+
105
+ 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).
106
+
107
+ ## Immutability
108
+
109
+ `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.
110
+
111
+ ## What about the `message` field?
112
+
113
+ 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.
114
+
115
+ ## Performance notes
116
+
117
+ - **No redact configured** → zero overhead (no clone, no walk).
118
+ - **Logger-wide redact only** → one deep clone + one path-walk per `log()` call, shared by every channel.
119
+ - **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.
120
+ - Each path is matched independently; cost grows linearly with `paths.length`.
121
+
122
+ 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.
@@ -0,0 +1,169 @@
1
+ ---
2
+ name: test-logging-code
3
+ 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`.'
4
+ ---
5
+
6
+ # Testing — code that logs, and asserting on log output
7
+
8
+ Two scenarios: **silencing the logger during tests** (most common) and **asserting that a specific log line was emitted**.
9
+
10
+ ## Silence the logger during tests
11
+
12
+ Clear every channel once, globally. No output, no file handles, no noise.
13
+
14
+ ```ts title="src/setupTests.ts"
15
+ import { log } from "@warlock.js/logger";
16
+
17
+ log.setChannels([]);
18
+ ```
19
+
20
+ Wire it in Vitest:
21
+
22
+ ```ts title="vitest.config.ts"
23
+ import { defineConfig } from "vitest/config";
24
+
25
+ export default defineConfig({
26
+ test: {
27
+ setupFiles: ["src/setupTests.ts"],
28
+ },
29
+ });
30
+ ```
31
+
32
+ ## Assert on log output — use a capturing channel
33
+
34
+ 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:
35
+
36
+ ```ts
37
+ import { LogChannel } from "@warlock.js/logger";
38
+ import type { LoggingData } from "@warlock.js/logger";
39
+
40
+ class CapturingChannel extends LogChannel {
41
+ public name = "capture";
42
+ public terminal = false;
43
+ public received: LoggingData[] = [];
44
+ public log(data: LoggingData) { this.received.push({ ...data }); }
45
+ }
46
+ ```
47
+
48
+ ### Test against the singleton
49
+
50
+ ```ts
51
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
52
+ import { log } from "@warlock.js/logger";
53
+ import { createUser } from "./users";
54
+
55
+ describe("createUser", () => {
56
+ let capture: CapturingChannel;
57
+ let originalChannels: typeof log.channels;
58
+
59
+ beforeEach(() => {
60
+ capture = new CapturingChannel();
61
+ originalChannels = log.channels;
62
+ log.channels = [capture];
63
+ });
64
+
65
+ afterEach(() => {
66
+ log.channels = originalChannels;
67
+ });
68
+
69
+ it("logs a success entry when the user is created", async () => {
70
+ await createUser({ email: "a@b.com" });
71
+
72
+ expect(capture.received).toContainEqual(
73
+ expect.objectContaining({
74
+ type: "success",
75
+ module: "users",
76
+ action: "create",
77
+ }),
78
+ );
79
+ });
80
+ });
81
+ ```
82
+
83
+ ### Test an isolated logger (avoid touching the singleton)
84
+
85
+ If the code under test accepts a logger via injection, create one per test:
86
+
87
+ ```ts
88
+ import { Logger } from "@warlock.js/logger";
89
+
90
+ const testLogger = new Logger();
91
+ const capture = new CapturingChannel();
92
+ testLogger.addChannel(capture);
93
+
94
+ await createUser({ email: "a@b.com" }, testLogger);
95
+
96
+ expect(capture.received[0]!.type).toBe("success");
97
+ ```
98
+
99
+ No cleanup needed — the local `Logger` is garbage-collected.
100
+
101
+ ## Why not spy on `log.info`?
102
+
103
+ `log` is a plain `Logger` instance (`export const log = new Logger()`) and every level method lives on the prototype, so `vi.spyOn(log, "info")` *does* technically work. Prefer the capturing channel anyway:
104
+
105
+ - A spy on `log.info` proves the method was **called**, not that an entry was **delivered** — it skips the whole pipeline (`minLevel` floor, redaction, per-channel `levels` / `filter`). A capturing channel asserts on the entry your code under test actually produced after all of that ran.
106
+ - 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.
107
+ - 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.
108
+
109
+ 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".
110
+
111
+ ## Testing a custom channel
112
+
113
+ Write specs against the channel directly; don't route through `Logger`:
114
+
115
+ ```ts
116
+ import { describe, it, expect, vi } from "vitest";
117
+ import { SlackLog } from "./slack-log";
118
+
119
+ describe("SlackLog", () => {
120
+ it("skips non-error levels", async () => {
121
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response());
122
+
123
+ const channel = new SlackLog({ webhookUrl: "https://test", levels: ["error"] });
124
+ await channel.log({ type: "info", module: "x", action: "y", message: "z" });
125
+
126
+ expect(fetchSpy).not.toHaveBeenCalled();
127
+ });
128
+ });
129
+ ```
130
+
131
+ ## Testing `FileLog` and `JSONFileLog`
132
+
133
+ Use real temp directories — it's the only way to exercise file IO, rotation, chunking, and JSON I/O with fidelity:
134
+
135
+ ```ts
136
+ import fs from "fs";
137
+ import os from "os";
138
+ import path from "path";
139
+ import { randomUUID } from "node:crypto";
140
+
141
+ function tempDir() {
142
+ const dir = path.join(os.tmpdir(), "logger-test", randomUUID());
143
+ fs.mkdirSync(dir, { recursive: true });
144
+ return dir;
145
+ }
146
+ ```
147
+
148
+ Clean up in `afterEach(() => fs.rmSync(dir, { recursive: true, force: true }))`.
149
+
150
+ ## Waiting for async init
151
+
152
+ `LogChannel.init()` runs inside a `setTimeout(0)`. Before asserting on post-init behavior, yield once:
153
+
154
+ ```ts
155
+ const channel = new FileLog({ storagePath: tempDir() });
156
+ await new Promise((r) => setTimeout(r, 10));
157
+ // Now `channel.isInitialized` is true and it's safe to call `channel.log(...)` for real I/O.
158
+ ```
159
+
160
+ ## Testing `captureAnyUnhandledRejection`
161
+
162
+ Don't actually throw unhandled rejections in tests — emit the listener directly:
163
+
164
+ ```ts
165
+ captureAnyUnhandledRejection();
166
+ process.emit("unhandledRejection", new Error("test"), Promise.resolve());
167
+ ```
168
+
169
+ See [`@warlock.js/logger/capture-unhandled-errors/SKILL.md`](@warlock.js/logger/capture-unhandled-errors/SKILL.md) for a full example.
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: use-log-helpers
3
+ 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`.'
4
+ ---
5
+
6
+ # Helpers — `assert`, `timer`
7
+
8
+ 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.
9
+
10
+ ## `log.assert(condition, module, action, message, context?)`
11
+
12
+ 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.
13
+
14
+ ```ts
15
+ log.assert(user !== null, "auth", "session", "user vanished mid-flight", {
16
+ sessionId,
17
+ });
18
+
19
+ // truthy → no log call
20
+ // falsy → equivalent to log.error("auth", "session", "user vanished...", { sessionId })
21
+ ```
22
+
23
+ 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`.
24
+
25
+ ### Why not `console.assert`?
26
+
27
+ `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).
28
+
29
+ ## `log.timer(module, action)`
30
+
31
+ Returns an end-function. Calling it emits an `info` entry with `completed in <ms>ms` and a `durationMs` field in `context`.
32
+
33
+ ```ts
34
+ const end = log.timer("db", "users.findById");
35
+ const user = await usersRepo.findById(id);
36
+ end({ id, found: !!user });
37
+ // ℹ [db] [users.findById] completed in 12ms
38
+ // ↳ { durationMs: 12, id: "abc", found: true } (when ConsoleLog has showContext: true)
39
+ ```
40
+
41
+ Common patterns:
42
+
43
+ ```ts
44
+ // Around an HTTP handler
45
+ async function handle(req) {
46
+ const end = log.timer("http", `${req.method} ${req.url}`);
47
+ try {
48
+ return await runHandler(req);
49
+ } finally {
50
+ end({ status: res.statusCode });
51
+ }
52
+ }
53
+
54
+ // Around a job
55
+ const end = log.timer("jobs", "nightly-report");
56
+ await report.run();
57
+ end({ rowsProcessed: report.rowCount });
58
+ ```
59
+
60
+ `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.
61
+
62
+ ### Caveats
63
+
64
+ - The duration is `Date.now()` based — millisecond resolution. For sub-millisecond profiling, reach for `performance.now()` directly.
65
+ - The end-function captures `this` at construction; calling it after the logger is reconfigured still routes through the same `Logger` instance.
66
+ - `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.