@deployanyway/doggo-log 0.3.0 → 1.0.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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+
5
+ - Fetch the Context: async request scopes and redaction.
6
+ - Typed API, CLI integration, runnable codebase example and meaningful workflow tests.
7
+ - Stable contracts and migration guidance; original humor stays around accurate facts.
8
+
9
+ ## 0.4.0
10
+
11
+ 48 original lines: eight per log level. Default `barkMode: 'classic'` preserves the existing first-line commentary. Opt into `barkMode: 'rotate'` for variation without repeats until that level's eight lines have been emitted. A seed selects a repeatable starting point by prefix and level; omitted seed starts with the first line. Filtering and quiet mode do not consume rotation. Each child logger owns its own sequence.
12
+
13
+ ```sh
14
+
15
+
3
16
  ## 0.3.0 — 2026-10-08
4
17
 
5
18
  - Useful structured API/CLI additions described in README.
@@ -22,3 +35,4 @@
22
35
  - Six logging methods, level filtering, quiet mode, and custom prefixes.
23
36
  - Optional emojis, ANSI colors, timestamps, and JSON output.
24
37
  - CLI, tests, documentation, and Node 22/24 CI.
38
+ ```
package/MIGRATION.md CHANGED
@@ -3,3 +3,15 @@
3
3
  Node 22.13+ is required. Existing core APIs remain available; TypeScript declarations and CommonJS exports are new. The CLI now lives in src/cli.js behind the same executable path. Input/stdout behavior for new options is documented in README.
4
4
 
5
5
  Install 0.3.0 with npm. Seeds and exact humorous wording are version-specific. Do not treat jokes or heuristic scores as production evidence.
6
+
7
+ ## 0.3.0 to 0.4.0
8
+
9
+ 48 original commentary lines across six levels; opt-in seeded rotation; independent child sequences, no advancement on filtering or failed writes; barkLines catalog API and CLI controls. Classic commentary remains default.
10
+
11
+ Existing defaults and entry points remain available. The new commentary rotation is opt-in; classic first-line commentary remains the default.
12
+
13
+ ## 0.4.0 to stable 1.0.0
14
+
15
+ Intentional v1 changes: credential context keys redact by default, and text logs include nonempty context. Use redact:false only when you deliberately need the prior raw-context output. Existing levels, methods, JSON shape, formatting, child loggers, classic barks and opt-in rotation remain. New Node context subpath supports ESM and CommonJS; the core entry remains browser-adaptable.
16
+
17
+ See README for exact contracts, bounds and failure behavior.
package/README.md CHANGED
@@ -1,6 +1,69 @@
1
1
  # doggo-log
2
2
 
3
- > **Version 0.3.0:** install from npm with Node 22.13+ or Node 24. See MIGRATION.md for changes from 0.2.0.
3
+ ## Fetch the Context: async request scopes and redaction (1.0.0)
4
+
5
+ The Node-only @deployanyway/doggo-log/context entry exports createRequestLogger(options). Use one logger per service and run(context, callback, ...args) per request. AsyncLocalStorage carries the copied scalar context through awaited work; concurrent requests remain isolated. Child loggers share the active scope, and nested scopes restore the outer context when they finish.
6
+
7
+ ```js
8
+ import { createRequestLogger } from "@deployanyway/doggo-log/context";
9
+ const log = createRequestLogger({ json: true, context: { service: "api" } });
10
+ const db = log.child("database");
11
+ await log.run(
12
+ { requestId: "request-42", authorization: "private-header" },
13
+ async () => {
14
+ await Promise.resolve();
15
+ db.info("Query finished"); // requestId follows the work; authorization is redacted
16
+ },
17
+ );
18
+ log.dispose(); // only after all work has completed
19
+ ```
20
+
21
+ run preserves callback return values/promises/errors. Scoped fields override static context, nested fields merge, and getContext() returns a fresh raw scope copy (do not expose it publicly). Outside scopes only static context remains. dispose disables tracking and prevents future run calls. Do not dispose an active service per request. Async boundaries that lose Node context, worker threads and external processes require explicit handoff; scopes do not cross them automatically. Reference: [Node asynchronous context documentation](https://nodejs.org/api/async_context.html).
22
+
23
+ Redaction runs before writer callbacks and returned JSON/text. Default credential context keys are password, passwd, token, accessToken, refreshToken, authorization, cookie, secret and apiKey, case-insensitive with hyphens/underscores normalized. Configure redact: {keys: ['private'], values: ['literal-secret'], replacement: '[REDACTED]'}. keys replaces the default key list; values replaces exact literals in formatted messages, prefixes and string context values, longest first. redact: false explicitly disables it. Text logs now include supplied context, so request IDs are useful outside JSON too.
24
+
25
+ Redaction is scoped: context fields must be scalar; it does not inspect arbitrary objects formatted in message arguments, discover unknown secrets, decode transformed tokens or scrub another writer's output. Avoid logging credentials in arbitrary message objects. Protect context keys and configure known literal values where needed. Filtering skips providers/writers; failed writes preserve bark rotation.
26
+
27
+ ```sh
28
+ doggo-log info 'Request complete' --context '{"requestId":"42","authorization":"private"}' --json
29
+ doggo-log info 'token=demo-only' --redact-values '["demo-only"]' --json
30
+ node node_modules/@deployanyway/doggo-log/examples/fetch-context.mjs
31
+ ```
32
+
33
+ --redact-keys accepts comma-separated context keys; --redact-values accepts a JSON string array. The runnable Node example makes two concurrent loopback HTTP requests and demonstrates isolated redacted logs. Browser demos can preview formatting/context redaction; AsyncLocalStorage itself runs in Node, not a browser shim.
34
+
35
+ ## Stable v1 contract
36
+
37
+ Node 22.13+ or Node 24. MIT licensed. CLI flags, structured fields, ESM/CommonJS exports and declarations are covered by tests and installed-package checks. Existing 0.4 APIs remain available except the explicitly documented doggo-log redaction/text-context changes. Future incompatible public API changes require a major release; callers should consume structured fields rather than parse jokes. Exact humorous wording and seeded catalog choices are version-specific. No telemetry, external API keys or network service is needed for core use.
38
+
39
+ Run npm test, npm run lint, npm run format:check, npm run coverage, npm run test:types and npm run verify:package from a source checkout. Runnable examples are shipped under examples/. The root demo is https://deployanyway.github.io/.
40
+
41
+ ## Commentary that stays useful (0.4.0)
42
+
43
+ 48 original lines: eight per log level. Default `barkMode: 'classic'` preserves the existing first-line commentary. Opt into `barkMode: 'rotate'` for variation without repeats until that level's eight lines have been emitted. A seed selects a repeatable starting point by prefix and level; omitted seed starts with the first line. Filtering and quiet mode do not consume rotation. Each child logger owns its own sequence.
44
+
45
+ ```sh
46
+ npx @deployanyway/doggo-log warn 'Retry budget almost exhausted' --bark --bark-mode rotate --seed demo --json
47
+ ```
48
+
49
+ ```js
50
+ import { createDogLogger, barkLines } from "@deployanyway/doggo-log";
51
+ const log = createDogLogger({
52
+ bark: true,
53
+ barkMode: "rotate",
54
+ seed: "job-42",
55
+ json: true,
56
+ });
57
+ const job = log.child("worker", { jobId: "42" });
58
+ job.info("Job accepted");
59
+ job.warn("Retry budget almost exhausted");
60
+ job.success("Job complete");
61
+ console.log(barkLines("warn"));
62
+ ```
63
+
64
+ Caller messages and JSON context remain separate from commentary. `barkLines(level)` returns a fresh catalog copy. The logger is intentionally small: it does not provide persistence, transport, redaction or a production observability backend.
65
+
66
+ > **Version 0.4.0:** install from npm with Node 22.13+ or Node 24. See MIGRATION.md for changes from 0.2.0.
4
67
 
5
68
  [![npm version](https://img.shields.io/npm/v/%40deployanyway%2Fdoggo-log)](https://www.npmjs.com/package/@deployanyway/doggo-log)
6
69
  [![CI](https://github.com/DeployAnyway/doggo-log/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/DeployAnyway/doggo-log/actions/workflows/ci.yml)
package/context.d.cts ADDED
@@ -0,0 +1,14 @@
1
+ import type { DogLogger, DogOptions, LogContext } from "./index.js";
2
+ export type RequestLogger = DogLogger & {
3
+ run<T, A extends unknown[]>(
4
+ context: LogContext,
5
+ callback: (...args: A) => T,
6
+ ...args: A
7
+ ): T;
8
+ /** Returns a fresh copy of the current raw scope; do not expose it as a public response. */
9
+ getContext(): LogContext;
10
+ dispose(): void;
11
+ };
12
+ export function createRequestLogger(
13
+ options?: Omit<DogOptions, "contextProvider">,
14
+ ): RequestLogger;
package/context.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { DogLogger, DogOptions, LogContext } from "./index.js";
2
+ export type RequestLogger = DogLogger & {
3
+ run<T, A extends unknown[]>(
4
+ context: LogContext,
5
+ callback: (...args: A) => T,
6
+ ...args: A
7
+ ): T;
8
+ /** Returns a fresh copy of the current raw scope; do not expose it as a public response. */
9
+ getContext(): LogContext;
10
+ dispose(): void;
11
+ };
12
+ export function createRequestLogger(
13
+ options?: Omit<DogOptions, "contextProvider">,
14
+ ): RequestLogger;
@@ -0,0 +1,340 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/context.js
21
+ var context_exports = {};
22
+ __export(context_exports, {
23
+ createRequestLogger: () => createRequestLogger
24
+ });
25
+ module.exports = __toCommonJS(context_exports);
26
+ var import_node_async_hooks = require("node:async_hooks");
27
+
28
+ // src/index.js
29
+ var import_node_util = require("node:util");
30
+
31
+ // src/levels.js
32
+ var levels = {
33
+ debug: { rank: 10, emoji: "\u{1F50D}", color: 90 },
34
+ log: { rank: 20, emoji: "\u{1F436}", color: 37 },
35
+ info: { rank: 20, emoji: "\u{1F436}", color: 36 },
36
+ success: { rank: 20, emoji: "\u{1F43E}", color: 32 },
37
+ warn: { rank: 30, emoji: "\u{1F9B4}", color: 33 },
38
+ error: { rank: 40, emoji: "\u{1F6A8}", color: 31 }
39
+ };
40
+
41
+ // src/commentary.js
42
+ var lines = {
43
+ debug: [
44
+ "Sniffing for clues.",
45
+ "Follow the trace, not the squirrel.",
46
+ "One breakpoint. Two ears. Maximum attention.",
47
+ "The missing value has left a scent trail.",
48
+ "Logging the evidence before chasing the theory.",
49
+ "The stack trace is our walking route.",
50
+ "Small reproduction, big detective energy.",
51
+ "Dallas found a clue. Benji would like to inspect the keyboard."
52
+ ],
53
+ log: [
54
+ "Filed under things I sniffed.",
55
+ "Fetch complete. Filing the interesting bit.",
56
+ "Another breadcrumb for future-us.",
57
+ "This line has been approved by the tail department.",
58
+ "A small update with excellent ears.",
59
+ "Evidence delivered without chewing it.",
60
+ "Keeping a trail through the code forest.",
61
+ "Benji brought the log. Dallas brought enthusiasm."
62
+ ],
63
+ info: [
64
+ "Good to know. Good dog to tell you.",
65
+ "A useful update, delivered at husky speed.",
66
+ "The facts have arrived wearing sensible paws.",
67
+ "Worth knowing before the next zoomie.",
68
+ "No alarm. Just a well-timed nose boop.",
69
+ "Status fetched. Tail at a responsible speed.",
70
+ "A little context saves a lot of barking.",
71
+ "Dallas and Benji have entered the observability business."
72
+ ],
73
+ success: [
74
+ "Treat budget approved.",
75
+ "Good result. Better evidence. Best dog.",
76
+ "The check passed. Save some applause for monitoring.",
77
+ "One fewer problem between us and the walk.",
78
+ "Tail deployment successful.",
79
+ "The happy path has receipts today.",
80
+ "Achievement unlocked: boring, repeatable success.",
81
+ "Dallas celebrates. Benji is already planning the victory lap."
82
+ ],
83
+ warn: [
84
+ "Suspicious squirrel detected.",
85
+ "Ears up. This deserves a closer look.",
86
+ "Something smells odd; inspect before retrying.",
87
+ "A warning is a breadcrumb, not a dare.",
88
+ "The tail slowed down for a reason.",
89
+ "Check the evidence before this becomes an incident.",
90
+ "Potential trouble has arrived with muddy paws.",
91
+ "Benji heard something. Dallas recommends checking the logs."
92
+ ],
93
+ error: [
94
+ "The dog has fetched the incident report.",
95
+ "Read the first failure before chasing the pack.",
96
+ "The red light is evidence, not a personality review.",
97
+ "Pause the zoomies. Find the cause and the rollback.",
98
+ "This needs a fix, not louder barking.",
99
+ "Capture the reproduction while the scent is fresh.",
100
+ "The operation failed. The team still gets kindness.",
101
+ "Dallas and Benji are standing by with emotional support."
102
+ ]
103
+ };
104
+ function barkLines(level) {
105
+ if (typeof level !== "string" || !Object.hasOwn(lines, level))
106
+ throw new RangeError("Choose debug, log, info, success, warn or error.");
107
+ return [...lines[level]];
108
+ }
109
+ function commentaryIndex(seed, salt, length) {
110
+ if (seed === void 0) return 0;
111
+ let hash = 2166136261;
112
+ for (const char of salt + ":" + typeof seed + ":" + seed) {
113
+ hash ^= char.codePointAt(0);
114
+ hash = Math.imul(hash, 16777619) >>> 0;
115
+ }
116
+ return hash % length;
117
+ }
118
+
119
+ // src/redaction.js
120
+ var defaultKeys = [
121
+ "password",
122
+ "passwd",
123
+ "token",
124
+ "accessToken",
125
+ "refreshToken",
126
+ "authorization",
127
+ "cookie",
128
+ "secret",
129
+ "apiKey"
130
+ ];
131
+ var normalize = (key) => key.toLowerCase().replaceAll(/[-_]/g, "");
132
+ function copyContext(context) {
133
+ if (!context || typeof context !== "object" || Array.isArray(context))
134
+ throw new TypeError("context must be an object of scalar fields.");
135
+ for (const value of Object.values(context))
136
+ if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
137
+ throw new TypeError(
138
+ "Context values must be strings, finite numbers, booleans or null."
139
+ );
140
+ return { ...context };
141
+ }
142
+ function createRedactor(options) {
143
+ if (options === false)
144
+ return { text: (value) => value, context: copyContext };
145
+ if (options === void 0) options = {};
146
+ if (!options || typeof options !== "object" || Array.isArray(options))
147
+ throw new TypeError("redact must be false or an options object.");
148
+ const keys = options.keys ?? defaultKeys, values = options.values ?? [], replacement = options.replacement ?? "[REDACTED]";
149
+ for (const [name, list] of [
150
+ ["keys", keys],
151
+ ["values", values]
152
+ ])
153
+ if (!Array.isArray(list) || list.length > 100 || !list.every((value) => typeof value === "string" && value.length > 0))
154
+ throw new TypeError(
155
+ `Redaction ${name} must contain at most 100 nonempty strings.`
156
+ );
157
+ if (typeof replacement !== "string")
158
+ throw new TypeError("Redaction replacement must be a string.");
159
+ const protectedKeys = new Set(keys.map(normalize));
160
+ const literals = [...new Set(values)].sort((a, b) => b.length - a.length);
161
+ const text = (value) => literals.reduce(
162
+ (output, secret) => output.split(secret).join(replacement),
163
+ value
164
+ );
165
+ return {
166
+ text,
167
+ context: (context) => Object.fromEntries(
168
+ Object.entries(copyContext(context)).map(([key, value]) => [
169
+ key,
170
+ protectedKeys.has(normalize(key)) ? replacement : typeof value === "string" ? text(value) : value
171
+ ])
172
+ )
173
+ };
174
+ }
175
+
176
+ // src/index.js
177
+ function createDogLogger(options = {}) {
178
+ if (!options || typeof options !== "object" || Array.isArray(options))
179
+ throw new TypeError("Options must be an object.");
180
+ const config = {
181
+ emoji: true,
182
+ bark: false,
183
+ barkMode: "classic",
184
+ seed: void 0,
185
+ color: false,
186
+ timestamp: false,
187
+ json: false,
188
+ quiet: false,
189
+ prefix: "",
190
+ level: "info",
191
+ context: {},
192
+ contextProvider: () => ({}),
193
+ write: (line, level) => level === "warn" || level === "error" ? console.error(line) : console.log(line),
194
+ clock: () => /* @__PURE__ */ new Date(),
195
+ ...options
196
+ };
197
+ for (const key of ["emoji", "color", "timestamp", "json", "quiet", "bark"]) {
198
+ if (typeof config[key] !== "boolean")
199
+ throw new TypeError(`${key} must be a boolean.`);
200
+ }
201
+ if (typeof config.prefix !== "string")
202
+ throw new TypeError("prefix must be a string.");
203
+ if (!config.context || typeof config.context !== "object" || Array.isArray(config.context))
204
+ throw new TypeError("context must be an object of scalar fields.");
205
+ for (const value of Object.values(config.context))
206
+ if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
207
+ throw new TypeError(
208
+ "Context values must be strings, finite numbers, booleans or null."
209
+ );
210
+ config.context = copyContext(config.context);
211
+ if (typeof config.contextProvider !== "function")
212
+ throw new TypeError("contextProvider must be a function.");
213
+ const redactor = createRedactor(config.redact);
214
+ if (config.redact !== false)
215
+ config.redact = {
216
+ ...config.redact,
217
+ ...config.redact?.keys ? { keys: [...config.redact.keys] } : {},
218
+ ...config.redact?.values ? { values: [...config.redact.values] } : {}
219
+ };
220
+ if (typeof config.level !== "string" || !Object.hasOwn(levels, config.level))
221
+ throw new RangeError(
222
+ `level must be one of: ${Object.keys(levels).join(", ")}.`
223
+ );
224
+ if (typeof config.write !== "function" || typeof config.clock !== "function")
225
+ throw new TypeError("write and clock must be functions.");
226
+ if (!["classic", "rotate"].includes(config.barkMode))
227
+ throw new RangeError("barkMode must be classic or rotate.");
228
+ if (config.seed !== void 0 && typeof config.seed !== "string" && !(typeof config.seed === "number" && Number.isFinite(config.seed)))
229
+ throw new TypeError("seed must be a string or finite number.");
230
+ const counters = {};
231
+ const logger = Object.fromEntries(
232
+ Object.entries(levels).map(([level, style]) => [
233
+ level,
234
+ (...args) => {
235
+ if (config.quiet || style.rank < levels[config.level].rank)
236
+ return void 0;
237
+ const message = redactor.text((0, import_node_util.format)(...args));
238
+ const context = redactor.context({
239
+ ...config.context,
240
+ ...copyContext(config.contextProvider())
241
+ });
242
+ const pool = config.bark ? barkLines(level) : void 0;
243
+ const index = config.barkMode === "rotate" ? (commentaryIndex(
244
+ config.seed,
245
+ config.prefix + ":" + level,
246
+ pool?.length ?? 1
247
+ ) + (counters[level] ?? 0)) % (pool?.length ?? 1) : 0;
248
+ const commentary = pool?.[index];
249
+ let timestamp;
250
+ if (config.timestamp) {
251
+ const date = config.clock();
252
+ if (!(date instanceof Date) || !Number.isFinite(date.getTime()))
253
+ throw new TypeError("clock must return a valid Date.");
254
+ timestamp = date.toISOString();
255
+ }
256
+ let line;
257
+ if (config.json) {
258
+ line = JSON.stringify({
259
+ level,
260
+ message,
261
+ ...Object.keys(context).length ? { context } : {},
262
+ ...commentary ? { commentary } : {},
263
+ ...config.prefix ? { prefix: redactor.text(config.prefix) } : {},
264
+ ...timestamp ? { timestamp } : {}
265
+ });
266
+ } else {
267
+ line = [
268
+ timestamp,
269
+ redactor.text(config.prefix),
270
+ config.emoji ? style.emoji : void 0,
271
+ level.toUpperCase().padEnd(7),
272
+ message,
273
+ Object.keys(context).length ? JSON.stringify(context) : void 0,
274
+ commentary
275
+ ].filter((part) => part !== void 0 && part !== "").join(" ");
276
+ if (config.color) line = `\x1B[${style.color}m${line}\x1B[0m`;
277
+ }
278
+ config.write(line, level);
279
+ if (config.bark)
280
+ counters[level] = ((counters[level] ?? 0) + 1) % pool.length;
281
+ return line;
282
+ }
283
+ ])
284
+ );
285
+ logger.child = (prefix, context = {}) => {
286
+ if (!context || typeof context !== "object" || Array.isArray(context))
287
+ throw new TypeError("context must be an object.");
288
+ if (typeof prefix !== "string" || !prefix.trim())
289
+ throw new TypeError("Child prefix must be a nonempty string.");
290
+ return createDogLogger({
291
+ ...config,
292
+ context: { ...config.context, ...context },
293
+ prefix: [config.prefix, prefix.trim()].filter(Boolean).join(":")
294
+ });
295
+ };
296
+ logger.withContext = (context) => {
297
+ if (!context || typeof context !== "object" || Array.isArray(context))
298
+ throw new TypeError("context must be an object.");
299
+ return createDogLogger({
300
+ ...config,
301
+ context: { ...config.context, ...context }
302
+ });
303
+ };
304
+ return logger;
305
+ }
306
+ var doglog = createDogLogger();
307
+
308
+ // src/context.js
309
+ function createRequestLogger(options = {}) {
310
+ if (!options || typeof options !== "object" || Array.isArray(options))
311
+ throw new TypeError("Options must be an object.");
312
+ if (Object.hasOwn(options, "contextProvider"))
313
+ throw new TypeError("createRequestLogger owns its context provider.");
314
+ const storage = new import_node_async_hooks.AsyncLocalStorage();
315
+ let disposed = false;
316
+ const logger = createDogLogger({
317
+ ...options,
318
+ contextProvider: () => storage.getStore() ?? {}
319
+ });
320
+ logger.run = (context, callback, ...args) => {
321
+ if (disposed) throw new Error("Request logger has been disposed.");
322
+ if (typeof callback !== "function")
323
+ throw new TypeError("Request callback must be a function.");
324
+ const scope = Object.freeze({
325
+ ...storage.getStore(),
326
+ ...copyContext(context)
327
+ });
328
+ return storage.run(scope, callback, ...args);
329
+ };
330
+ logger.getContext = () => ({ ...storage.getStore() });
331
+ logger.dispose = () => {
332
+ storage.disable();
333
+ disposed = true;
334
+ };
335
+ return logger;
336
+ }
337
+ // Annotate the CommonJS export names for ESM import in node:
338
+ 0 && (module.exports = {
339
+ createRequestLogger
340
+ });
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.js
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ barkLines: () => barkLines,
23
24
  createDogLogger: () => createDogLogger,
24
25
  doglog: () => doglog
25
26
  });
@@ -36,21 +37,150 @@ var levels = {
36
37
  error: { rank: 40, emoji: "\u{1F6A8}", color: 31 }
37
38
  };
38
39
 
39
- // src/index.js
40
- var barks = {
41
- debug: "Sniffing for clues.",
42
- log: "Filed under things I sniffed.",
43
- info: "Good to know. Good dog to tell you.",
44
- success: "Treat budget approved.",
45
- warn: "Suspicious squirrel detected.",
46
- error: "The dog has fetched the incident report."
40
+ // src/commentary.js
41
+ var lines = {
42
+ debug: [
43
+ "Sniffing for clues.",
44
+ "Follow the trace, not the squirrel.",
45
+ "One breakpoint. Two ears. Maximum attention.",
46
+ "The missing value has left a scent trail.",
47
+ "Logging the evidence before chasing the theory.",
48
+ "The stack trace is our walking route.",
49
+ "Small reproduction, big detective energy.",
50
+ "Dallas found a clue. Benji would like to inspect the keyboard."
51
+ ],
52
+ log: [
53
+ "Filed under things I sniffed.",
54
+ "Fetch complete. Filing the interesting bit.",
55
+ "Another breadcrumb for future-us.",
56
+ "This line has been approved by the tail department.",
57
+ "A small update with excellent ears.",
58
+ "Evidence delivered without chewing it.",
59
+ "Keeping a trail through the code forest.",
60
+ "Benji brought the log. Dallas brought enthusiasm."
61
+ ],
62
+ info: [
63
+ "Good to know. Good dog to tell you.",
64
+ "A useful update, delivered at husky speed.",
65
+ "The facts have arrived wearing sensible paws.",
66
+ "Worth knowing before the next zoomie.",
67
+ "No alarm. Just a well-timed nose boop.",
68
+ "Status fetched. Tail at a responsible speed.",
69
+ "A little context saves a lot of barking.",
70
+ "Dallas and Benji have entered the observability business."
71
+ ],
72
+ success: [
73
+ "Treat budget approved.",
74
+ "Good result. Better evidence. Best dog.",
75
+ "The check passed. Save some applause for monitoring.",
76
+ "One fewer problem between us and the walk.",
77
+ "Tail deployment successful.",
78
+ "The happy path has receipts today.",
79
+ "Achievement unlocked: boring, repeatable success.",
80
+ "Dallas celebrates. Benji is already planning the victory lap."
81
+ ],
82
+ warn: [
83
+ "Suspicious squirrel detected.",
84
+ "Ears up. This deserves a closer look.",
85
+ "Something smells odd; inspect before retrying.",
86
+ "A warning is a breadcrumb, not a dare.",
87
+ "The tail slowed down for a reason.",
88
+ "Check the evidence before this becomes an incident.",
89
+ "Potential trouble has arrived with muddy paws.",
90
+ "Benji heard something. Dallas recommends checking the logs."
91
+ ],
92
+ error: [
93
+ "The dog has fetched the incident report.",
94
+ "Read the first failure before chasing the pack.",
95
+ "The red light is evidence, not a personality review.",
96
+ "Pause the zoomies. Find the cause and the rollback.",
97
+ "This needs a fix, not louder barking.",
98
+ "Capture the reproduction while the scent is fresh.",
99
+ "The operation failed. The team still gets kindness.",
100
+ "Dallas and Benji are standing by with emotional support."
101
+ ]
47
102
  };
103
+ function barkLines(level) {
104
+ if (typeof level !== "string" || !Object.hasOwn(lines, level))
105
+ throw new RangeError("Choose debug, log, info, success, warn or error.");
106
+ return [...lines[level]];
107
+ }
108
+ function commentaryIndex(seed, salt, length) {
109
+ if (seed === void 0) return 0;
110
+ let hash = 2166136261;
111
+ for (const char of salt + ":" + typeof seed + ":" + seed) {
112
+ hash ^= char.codePointAt(0);
113
+ hash = Math.imul(hash, 16777619) >>> 0;
114
+ }
115
+ return hash % length;
116
+ }
117
+
118
+ // src/redaction.js
119
+ var defaultKeys = [
120
+ "password",
121
+ "passwd",
122
+ "token",
123
+ "accessToken",
124
+ "refreshToken",
125
+ "authorization",
126
+ "cookie",
127
+ "secret",
128
+ "apiKey"
129
+ ];
130
+ var normalize = (key) => key.toLowerCase().replaceAll(/[-_]/g, "");
131
+ function copyContext(context) {
132
+ if (!context || typeof context !== "object" || Array.isArray(context))
133
+ throw new TypeError("context must be an object of scalar fields.");
134
+ for (const value of Object.values(context))
135
+ if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
136
+ throw new TypeError(
137
+ "Context values must be strings, finite numbers, booleans or null."
138
+ );
139
+ return { ...context };
140
+ }
141
+ function createRedactor(options) {
142
+ if (options === false)
143
+ return { text: (value) => value, context: copyContext };
144
+ if (options === void 0) options = {};
145
+ if (!options || typeof options !== "object" || Array.isArray(options))
146
+ throw new TypeError("redact must be false or an options object.");
147
+ const keys = options.keys ?? defaultKeys, values = options.values ?? [], replacement = options.replacement ?? "[REDACTED]";
148
+ for (const [name, list] of [
149
+ ["keys", keys],
150
+ ["values", values]
151
+ ])
152
+ if (!Array.isArray(list) || list.length > 100 || !list.every((value) => typeof value === "string" && value.length > 0))
153
+ throw new TypeError(
154
+ `Redaction ${name} must contain at most 100 nonempty strings.`
155
+ );
156
+ if (typeof replacement !== "string")
157
+ throw new TypeError("Redaction replacement must be a string.");
158
+ const protectedKeys = new Set(keys.map(normalize));
159
+ const literals = [...new Set(values)].sort((a, b) => b.length - a.length);
160
+ const text = (value) => literals.reduce(
161
+ (output, secret) => output.split(secret).join(replacement),
162
+ value
163
+ );
164
+ return {
165
+ text,
166
+ context: (context) => Object.fromEntries(
167
+ Object.entries(copyContext(context)).map(([key, value]) => [
168
+ key,
169
+ protectedKeys.has(normalize(key)) ? replacement : typeof value === "string" ? text(value) : value
170
+ ])
171
+ )
172
+ };
173
+ }
174
+
175
+ // src/index.js
48
176
  function createDogLogger(options = {}) {
49
177
  if (!options || typeof options !== "object" || Array.isArray(options))
50
178
  throw new TypeError("Options must be an object.");
51
179
  const config = {
52
180
  emoji: true,
53
181
  bark: false,
182
+ barkMode: "classic",
183
+ seed: void 0,
54
184
  color: false,
55
185
  timestamp: false,
56
186
  json: false,
@@ -58,6 +188,7 @@ function createDogLogger(options = {}) {
58
188
  prefix: "",
59
189
  level: "info",
60
190
  context: {},
191
+ contextProvider: () => ({}),
61
192
  write: (line, level) => level === "warn" || level === "error" ? console.error(line) : console.log(line),
62
193
  clock: () => /* @__PURE__ */ new Date(),
63
194
  ...options
@@ -75,21 +206,45 @@ function createDogLogger(options = {}) {
75
206
  throw new TypeError(
76
207
  "Context values must be strings, finite numbers, booleans or null."
77
208
  );
78
- config.context = { ...config.context };
209
+ config.context = copyContext(config.context);
210
+ if (typeof config.contextProvider !== "function")
211
+ throw new TypeError("contextProvider must be a function.");
212
+ const redactor = createRedactor(config.redact);
213
+ if (config.redact !== false)
214
+ config.redact = {
215
+ ...config.redact,
216
+ ...config.redact?.keys ? { keys: [...config.redact.keys] } : {},
217
+ ...config.redact?.values ? { values: [...config.redact.values] } : {}
218
+ };
79
219
  if (typeof config.level !== "string" || !Object.hasOwn(levels, config.level))
80
220
  throw new RangeError(
81
221
  `level must be one of: ${Object.keys(levels).join(", ")}.`
82
222
  );
83
223
  if (typeof config.write !== "function" || typeof config.clock !== "function")
84
224
  throw new TypeError("write and clock must be functions.");
225
+ if (!["classic", "rotate"].includes(config.barkMode))
226
+ throw new RangeError("barkMode must be classic or rotate.");
227
+ if (config.seed !== void 0 && typeof config.seed !== "string" && !(typeof config.seed === "number" && Number.isFinite(config.seed)))
228
+ throw new TypeError("seed must be a string or finite number.");
229
+ const counters = {};
85
230
  const logger = Object.fromEntries(
86
231
  Object.entries(levels).map(([level, style]) => [
87
232
  level,
88
233
  (...args) => {
89
234
  if (config.quiet || style.rank < levels[config.level].rank)
90
235
  return void 0;
91
- const message = (0, import_node_util.format)(...args);
92
- const commentary = config.bark ? barks[level] : void 0;
236
+ const message = redactor.text((0, import_node_util.format)(...args));
237
+ const context = redactor.context({
238
+ ...config.context,
239
+ ...copyContext(config.contextProvider())
240
+ });
241
+ const pool = config.bark ? barkLines(level) : void 0;
242
+ const index = config.barkMode === "rotate" ? (commentaryIndex(
243
+ config.seed,
244
+ config.prefix + ":" + level,
245
+ pool?.length ?? 1
246
+ ) + (counters[level] ?? 0)) % (pool?.length ?? 1) : 0;
247
+ const commentary = pool?.[index];
93
248
  let timestamp;
94
249
  if (config.timestamp) {
95
250
  const date = config.clock();
@@ -102,23 +257,26 @@ function createDogLogger(options = {}) {
102
257
  line = JSON.stringify({
103
258
  level,
104
259
  message,
105
- ...Object.keys(config.context).length ? { context: { ...config.context } } : {},
260
+ ...Object.keys(context).length ? { context } : {},
106
261
  ...commentary ? { commentary } : {},
107
- ...config.prefix ? { prefix: config.prefix } : {},
262
+ ...config.prefix ? { prefix: redactor.text(config.prefix) } : {},
108
263
  ...timestamp ? { timestamp } : {}
109
264
  });
110
265
  } else {
111
266
  line = [
112
267
  timestamp,
113
- config.prefix,
268
+ redactor.text(config.prefix),
114
269
  config.emoji ? style.emoji : void 0,
115
270
  level.toUpperCase().padEnd(7),
116
271
  message,
272
+ Object.keys(context).length ? JSON.stringify(context) : void 0,
117
273
  commentary
118
274
  ].filter((part) => part !== void 0 && part !== "").join(" ");
119
275
  if (config.color) line = `\x1B[${style.color}m${line}\x1B[0m`;
120
276
  }
121
277
  config.write(line, level);
278
+ if (config.bark)
279
+ counters[level] = ((counters[level] ?? 0) + 1) % pool.length;
122
280
  return line;
123
281
  }
124
282
  ])
@@ -147,6 +305,7 @@ function createDogLogger(options = {}) {
147
305
  var doglog = createDogLogger();
148
306
  // Annotate the CommonJS export names for ESM import in node:
149
307
  0 && (module.exports = {
308
+ barkLines,
150
309
  createDogLogger,
151
310
  doglog
152
311
  });
@@ -0,0 +1,7 @@
1
+ import { doglog, createDogLogger } from "@deployanyway/doggo-log";
2
+
3
+ doglog.info("Server started");
4
+ doglog.success("Tests passed");
5
+ doglog.warn("API is getting slow");
6
+ doglog.error("Database connection failed");
7
+ createDogLogger({ json: true, prefix: "demo" }).info("Ready");
@@ -0,0 +1,55 @@
1
+ import { createServer, get } from "node:http";
2
+ import { setImmediate as tick } from "node:timers/promises";
3
+ import { createRequestLogger } from "@deployanyway/doggo-log/context";
4
+ const log = createRequestLogger({
5
+ json: true,
6
+ context: { service: "demo-api" },
7
+ }),
8
+ db = log.child("database");
9
+ let nextId = 0;
10
+ const server = createServer((request, response) => {
11
+ void log.run(
12
+ {
13
+ requestId: String(++nextId),
14
+ authorization: request.headers.authorization ?? null,
15
+ },
16
+ async () => {
17
+ try {
18
+ log.info("Request started");
19
+ await tick();
20
+ db.info("Query complete");
21
+ response.end(JSON.stringify({ requestId: log.getContext().requestId }));
22
+ } catch (error) {
23
+ log.error(error.message);
24
+ response.statusCode = 500;
25
+ response.end("Failed");
26
+ }
27
+ },
28
+ );
29
+ });
30
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
31
+ const port = server.address().port;
32
+ try {
33
+ const request = () =>
34
+ new Promise((resolve, reject) => {
35
+ get(
36
+ {
37
+ hostname: "127.0.0.1",
38
+ port,
39
+ headers: { authorization: "demo-only-secret" },
40
+ },
41
+ (response) => {
42
+ response.resume();
43
+ response.on("end", () =>
44
+ response.statusCode === 200
45
+ ? resolve()
46
+ : reject(new Error("HTTP failed")),
47
+ );
48
+ },
49
+ ).on("error", reject);
50
+ });
51
+ await Promise.all([request(), request()]);
52
+ } finally {
53
+ await new Promise((resolve) => server.close(resolve));
54
+ log.dispose();
55
+ }
package/index.d.cts CHANGED
@@ -1,8 +1,13 @@
1
1
  export type Level = "debug" | "log" | "info" | "success" | "warn" | "error";
2
2
  export type LogContext = Record<string, string | number | boolean | null>;
3
3
  export interface DogOptions {
4
+ /** Defaults to common credential context keys. Literal values also redact formatted messages. */
5
+ redact?: false | { keys?: string[]; values?: string[]; replacement?: string };
6
+ contextProvider?: () => LogContext;
4
7
  emoji?: boolean;
5
8
  bark?: boolean;
9
+ barkMode?: "classic" | "rotate";
10
+ seed?: string | number;
6
11
  color?: boolean;
7
12
  timestamp?: boolean;
8
13
  json?: boolean;
@@ -20,3 +25,5 @@ export type DogLogger = Record<Level, LogMethod> & {
20
25
  };
21
26
  export function createDogLogger(options?: DogOptions): DogLogger;
22
27
  export const doglog: DogLogger;
28
+
29
+ export function barkLines(level: Level): string[];
package/index.d.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  export type Level = "debug" | "log" | "info" | "success" | "warn" | "error";
2
2
  export type LogContext = Record<string, string | number | boolean | null>;
3
3
  export interface DogOptions {
4
+ /** Defaults to common credential context keys. Literal values also redact formatted messages. */
5
+ redact?: false | { keys?: string[]; values?: string[]; replacement?: string };
6
+ contextProvider?: () => LogContext;
4
7
  emoji?: boolean;
5
8
  bark?: boolean;
9
+ barkMode?: "classic" | "rotate";
10
+ seed?: string | number;
6
11
  color?: boolean;
7
12
  timestamp?: boolean;
8
13
  json?: boolean;
@@ -20,3 +25,5 @@ export type DogLogger = Record<Level, LogMethod> & {
20
25
  };
21
26
  export function createDogLogger(options?: DogOptions): DogLogger;
22
27
  export const doglog: DogLogger;
28
+
29
+ export function barkLines(level: Level): string[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deployanyway/doggo-log",
3
- "version": "0.3.0",
4
- "description": "A tiny Node.js console logger with JSON output, log levels, and dog emojis. Good logs. Very good logs.",
3
+ "version": "1.0.0",
4
+ "description": "Node.js logs with async request context, JSON, levels and configurable redaction. Good logs. Very good logs. Fewer lost request IDs.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -13,6 +13,16 @@
13
13
  "types": "./index.d.cts",
14
14
  "default": "./dist/index.cjs"
15
15
  }
16
+ },
17
+ "./context": {
18
+ "import": {
19
+ "types": "./context.d.ts",
20
+ "default": "./src/context.js"
21
+ },
22
+ "require": {
23
+ "types": "./context.d.cts",
24
+ "default": "./dist/context.cjs"
25
+ }
16
26
  }
17
27
  },
18
28
  "bin": {
@@ -27,7 +37,10 @@
27
37
  "dist",
28
38
  "index.d.ts",
29
39
  "index.d.cts",
30
- "MIGRATION.md"
40
+ "MIGRATION.md",
41
+ "context.d.ts",
42
+ "context.d.cts",
43
+ "examples"
31
44
  ],
32
45
  "engines": {
33
46
  "node": ">=22.13"
@@ -76,7 +89,10 @@
76
89
  "dog",
77
90
  "typescript",
78
91
  "humor",
79
- "deployanyway"
92
+ "deployanyway",
93
+ "async-local-storage",
94
+ "request-id",
95
+ "redaction"
80
96
  ],
81
97
  "main": "./dist/index.cjs",
82
98
  "types": "./index.d.ts",
package/src/cli.js CHANGED
@@ -15,8 +15,12 @@ try {
15
15
  json: { type: "boolean" },
16
16
  stdin: { type: "boolean" },
17
17
  context: { type: "string" },
18
+ "redact-keys": { type: "string" },
19
+ "redact-values": { type: "string" },
18
20
  "no-color": { type: "boolean" },
19
21
  bark: { type: "boolean" },
22
+ "bark-mode": { type: "string" },
23
+ seed: { type: "string" },
20
24
  timestamp: { type: "boolean" },
21
25
  quiet: { type: "boolean" },
22
26
  color: { type: "boolean" },
@@ -27,7 +31,7 @@ try {
27
31
  });
28
32
  if (values.help) {
29
33
  console.log(
30
- "Usage: doggo-log <method> <message> [options]\n\nMethods: log, info, success, warn, error, debug. --stdin reads a bounded message; arguments win. --context accepts JSON scalar fields for JSON logs; --no-color and NO_COLOR disable ANSI.\nOptions:\n --bark Add useful dog commentary\n --json Emit JSON\n --timestamp Include an ISO timestamp\n --no-emoji Disable emojis\n --color Enable ANSI colors\n --prefix text Add a prefix\n --level name Minimum level (CLI default: debug)\n --quiet Suppress output\n -h, --help Show help\n -v, --version Show version\n\nExit codes: 0 success; 2 invalid arguments. Logging an error exits 0.",
34
+ "Usage: doggo-log <method> <message> [options]\n\nMethods: log, info, success, warn, error, debug. Credential context keys redact by default. --redact-keys accepts comma-separated keys; --redact-values accepts a JSON string array. Async request scopes use the Node /context API. --stdin reads a bounded message; arguments win. --context accepts JSON scalar fields for JSON logs; --no-color and NO_COLOR disable ANSI.\nOptions:\n --bark Add useful dog commentary\n --bark-mode classic|rotate Classic line or varied commentary\n --seed text Repeatable rotation starting point\n --json Emit JSON\n --timestamp Include an ISO timestamp\n --no-emoji Disable emojis\n --color Enable ANSI colors\n --prefix text Add a prefix\n --level name Minimum level (CLI default: debug)\n --quiet Suppress output\n -h, --help Show help\n -v, --version Show version\n\nExit codes: 0 success; 2 invalid arguments. Logging an error exits 0.",
31
35
  );
32
36
  } else if (values.version) {
33
37
  console.log(
@@ -47,11 +51,21 @@ try {
47
51
  const logger = createDogLogger({
48
52
  emoji: !values["no-emoji"],
49
53
  bark: values.bark ?? false,
54
+ barkMode: values["bark-mode"] ?? "classic",
55
+ seed: values.seed,
50
56
  color:
51
57
  !values["no-color"] &&
52
58
  !Object.hasOwn(process.env, "NO_COLOR") &&
53
59
  (values.color ?? false),
54
60
  context: values.context === undefined ? {} : JSON.parse(values.context),
61
+ redact: {
62
+ ...(values["redact-keys"] !== undefined
63
+ ? { keys: values["redact-keys"].split(",") }
64
+ : {}),
65
+ ...(values["redact-values"] !== undefined
66
+ ? { values: JSON.parse(values["redact-values"]) }
67
+ : {}),
68
+ },
55
69
  timestamp: values.timestamp ?? false,
56
70
  json: values.json ?? false,
57
71
  quiet: values.quiet ?? false,
@@ -0,0 +1,77 @@
1
+ // Original commentary; never replaces the caller's message.
2
+ const lines = {
3
+ debug: [
4
+ "Sniffing for clues.",
5
+ "Follow the trace, not the squirrel.",
6
+ "One breakpoint. Two ears. Maximum attention.",
7
+ "The missing value has left a scent trail.",
8
+ "Logging the evidence before chasing the theory.",
9
+ "The stack trace is our walking route.",
10
+ "Small reproduction, big detective energy.",
11
+ "Dallas found a clue. Benji would like to inspect the keyboard.",
12
+ ],
13
+ log: [
14
+ "Filed under things I sniffed.",
15
+ "Fetch complete. Filing the interesting bit.",
16
+ "Another breadcrumb for future-us.",
17
+ "This line has been approved by the tail department.",
18
+ "A small update with excellent ears.",
19
+ "Evidence delivered without chewing it.",
20
+ "Keeping a trail through the code forest.",
21
+ "Benji brought the log. Dallas brought enthusiasm.",
22
+ ],
23
+ info: [
24
+ "Good to know. Good dog to tell you.",
25
+ "A useful update, delivered at husky speed.",
26
+ "The facts have arrived wearing sensible paws.",
27
+ "Worth knowing before the next zoomie.",
28
+ "No alarm. Just a well-timed nose boop.",
29
+ "Status fetched. Tail at a responsible speed.",
30
+ "A little context saves a lot of barking.",
31
+ "Dallas and Benji have entered the observability business.",
32
+ ],
33
+ success: [
34
+ "Treat budget approved.",
35
+ "Good result. Better evidence. Best dog.",
36
+ "The check passed. Save some applause for monitoring.",
37
+ "One fewer problem between us and the walk.",
38
+ "Tail deployment successful.",
39
+ "The happy path has receipts today.",
40
+ "Achievement unlocked: boring, repeatable success.",
41
+ "Dallas celebrates. Benji is already planning the victory lap.",
42
+ ],
43
+ warn: [
44
+ "Suspicious squirrel detected.",
45
+ "Ears up. This deserves a closer look.",
46
+ "Something smells odd; inspect before retrying.",
47
+ "A warning is a breadcrumb, not a dare.",
48
+ "The tail slowed down for a reason.",
49
+ "Check the evidence before this becomes an incident.",
50
+ "Potential trouble has arrived with muddy paws.",
51
+ "Benji heard something. Dallas recommends checking the logs.",
52
+ ],
53
+ error: [
54
+ "The dog has fetched the incident report.",
55
+ "Read the first failure before chasing the pack.",
56
+ "The red light is evidence, not a personality review.",
57
+ "Pause the zoomies. Find the cause and the rollback.",
58
+ "This needs a fix, not louder barking.",
59
+ "Capture the reproduction while the scent is fresh.",
60
+ "The operation failed. The team still gets kindness.",
61
+ "Dallas and Benji are standing by with emotional support.",
62
+ ],
63
+ };
64
+ export function barkLines(level) {
65
+ if (typeof level !== "string" || !Object.hasOwn(lines, level))
66
+ throw new RangeError("Choose debug, log, info, success, warn or error.");
67
+ return [...lines[level]];
68
+ }
69
+ export function commentaryIndex(seed, salt, length) {
70
+ if (seed === undefined) return 0;
71
+ let hash = 2166136261;
72
+ for (const char of salt + ":" + typeof seed + ":" + seed) {
73
+ hash ^= char.codePointAt(0);
74
+ hash = Math.imul(hash, 16777619) >>> 0;
75
+ }
76
+ return hash % length;
77
+ }
package/src/context.js ADDED
@@ -0,0 +1,33 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createDogLogger } from "./index.js";
3
+ import { copyContext } from "./redaction.js";
4
+
5
+ /** A Node logger with isolated async request scopes. Dispose only after work completes. */
6
+ export function createRequestLogger(options = {}) {
7
+ if (!options || typeof options !== "object" || Array.isArray(options))
8
+ throw new TypeError("Options must be an object.");
9
+ if (Object.hasOwn(options, "contextProvider"))
10
+ throw new TypeError("createRequestLogger owns its context provider.");
11
+ const storage = new AsyncLocalStorage();
12
+ let disposed = false;
13
+ const logger = createDogLogger({
14
+ ...options,
15
+ contextProvider: () => storage.getStore() ?? {},
16
+ });
17
+ logger.run = (context, callback, ...args) => {
18
+ if (disposed) throw new Error("Request logger has been disposed.");
19
+ if (typeof callback !== "function")
20
+ throw new TypeError("Request callback must be a function.");
21
+ const scope = Object.freeze({
22
+ ...storage.getStore(),
23
+ ...copyContext(context),
24
+ });
25
+ return storage.run(scope, callback, ...args);
26
+ };
27
+ logger.getContext = () => ({ ...storage.getStore() });
28
+ logger.dispose = () => {
29
+ storage.disable();
30
+ disposed = true;
31
+ };
32
+ return logger;
33
+ }
package/src/index.js CHANGED
@@ -1,13 +1,8 @@
1
1
  import { format } from "node:util";
2
2
  import { levels } from "./levels.js";
3
- const barks = {
4
- debug: "Sniffing for clues.",
5
- log: "Filed under things I sniffed.",
6
- info: "Good to know. Good dog to tell you.",
7
- success: "Treat budget approved.",
8
- warn: "Suspicious squirrel detected.",
9
- error: "The dog has fetched the incident report.",
10
- };
3
+ import { barkLines, commentaryIndex } from "./commentary.js";
4
+ import { copyContext, createRedactor } from "./redaction.js";
5
+ export { barkLines };
11
6
 
12
7
  /**
13
8
  * Create a small logger. Methods return the emitted line, or undefined if filtered.
@@ -19,6 +14,8 @@ export function createDogLogger(options = {}) {
19
14
  const config = {
20
15
  emoji: true,
21
16
  bark: false,
17
+ barkMode: "classic",
18
+ seed: undefined,
22
19
  color: false,
23
20
  timestamp: false,
24
21
  json: false,
@@ -26,6 +23,7 @@ export function createDogLogger(options = {}) {
26
23
  prefix: "",
27
24
  level: "info",
28
25
  context: {},
26
+ contextProvider: () => ({}),
29
27
  write: (line, level) =>
30
28
  level === "warn" || level === "error"
31
29
  ? console.error(line)
@@ -55,21 +53,54 @@ export function createDogLogger(options = {}) {
55
53
  throw new TypeError(
56
54
  "Context values must be strings, finite numbers, booleans or null.",
57
55
  );
58
- config.context = { ...config.context };
56
+ config.context = copyContext(config.context);
57
+ if (typeof config.contextProvider !== "function")
58
+ throw new TypeError("contextProvider must be a function.");
59
+ const redactor = createRedactor(config.redact);
60
+ if (config.redact !== false)
61
+ config.redact = {
62
+ ...config.redact,
63
+ ...(config.redact?.keys ? { keys: [...config.redact.keys] } : {}),
64
+ ...(config.redact?.values ? { values: [...config.redact.values] } : {}),
65
+ };
59
66
  if (typeof config.level !== "string" || !Object.hasOwn(levels, config.level))
60
67
  throw new RangeError(
61
68
  `level must be one of: ${Object.keys(levels).join(", ")}.`,
62
69
  );
63
70
  if (typeof config.write !== "function" || typeof config.clock !== "function")
64
71
  throw new TypeError("write and clock must be functions.");
72
+ if (!["classic", "rotate"].includes(config.barkMode))
73
+ throw new RangeError("barkMode must be classic or rotate.");
74
+ if (
75
+ config.seed !== undefined &&
76
+ typeof config.seed !== "string" &&
77
+ !(typeof config.seed === "number" && Number.isFinite(config.seed))
78
+ )
79
+ throw new TypeError("seed must be a string or finite number.");
80
+ const counters = {};
65
81
  const logger = Object.fromEntries(
66
82
  Object.entries(levels).map(([level, style]) => [
67
83
  level,
68
84
  (...args) => {
69
85
  if (config.quiet || style.rank < levels[config.level].rank)
70
86
  return undefined;
71
- const message = format(...args);
72
- const commentary = config.bark ? barks[level] : undefined;
87
+ const message = redactor.text(format(...args));
88
+ const context = redactor.context({
89
+ ...config.context,
90
+ ...copyContext(config.contextProvider()),
91
+ });
92
+ const pool = config.bark ? barkLines(level) : undefined;
93
+ const index =
94
+ config.barkMode === "rotate"
95
+ ? (commentaryIndex(
96
+ config.seed,
97
+ config.prefix + ":" + level,
98
+ pool?.length ?? 1,
99
+ ) +
100
+ (counters[level] ?? 0)) %
101
+ (pool?.length ?? 1)
102
+ : 0;
103
+ const commentary = pool?.[index];
73
104
  let timestamp;
74
105
  if (config.timestamp) {
75
106
  const date = config.clock();
@@ -82,20 +113,19 @@ export function createDogLogger(options = {}) {
82
113
  line = JSON.stringify({
83
114
  level,
84
115
  message,
85
- ...(Object.keys(config.context).length
86
- ? { context: { ...config.context } }
87
- : {}),
116
+ ...(Object.keys(context).length ? { context } : {}),
88
117
  ...(commentary ? { commentary } : {}),
89
- ...(config.prefix ? { prefix: config.prefix } : {}),
118
+ ...(config.prefix ? { prefix: redactor.text(config.prefix) } : {}),
90
119
  ...(timestamp ? { timestamp } : {}),
91
120
  });
92
121
  } else {
93
122
  line = [
94
123
  timestamp,
95
- config.prefix,
124
+ redactor.text(config.prefix),
96
125
  config.emoji ? style.emoji : undefined,
97
126
  level.toUpperCase().padEnd(7),
98
127
  message,
128
+ Object.keys(context).length ? JSON.stringify(context) : undefined,
99
129
  commentary,
100
130
  ]
101
131
  .filter((part) => part !== undefined && part !== "")
@@ -103,6 +133,8 @@ export function createDogLogger(options = {}) {
103
133
  if (config.color) line = `\u001b[${style.color}m${line}\u001b[0m`;
104
134
  }
105
135
  config.write(line, level);
136
+ if (config.bark)
137
+ counters[level] = ((counters[level] ?? 0) + 1) % pool.length;
106
138
  return line;
107
139
  },
108
140
  ]),
@@ -0,0 +1,73 @@
1
+ const defaultKeys = [
2
+ "password",
3
+ "passwd",
4
+ "token",
5
+ "accessToken",
6
+ "refreshToken",
7
+ "authorization",
8
+ "cookie",
9
+ "secret",
10
+ "apiKey",
11
+ ];
12
+ const normalize = (key) => key.toLowerCase().replaceAll(/[-_]/g, "");
13
+ export function copyContext(context) {
14
+ if (!context || typeof context !== "object" || Array.isArray(context))
15
+ throw new TypeError("context must be an object of scalar fields.");
16
+ for (const value of Object.values(context))
17
+ if (!(
18
+ value === null ||
19
+ typeof value === "string" ||
20
+ typeof value === "boolean" ||
21
+ (typeof value === "number" && Number.isFinite(value))
22
+ ))
23
+ throw new TypeError(
24
+ "Context values must be strings, finite numbers, booleans or null.",
25
+ );
26
+ return { ...context };
27
+ }
28
+ export function createRedactor(options) {
29
+ if (options === false)
30
+ return { text: (value) => value, context: copyContext };
31
+ if (options === undefined) options = {};
32
+ if (!options || typeof options !== "object" || Array.isArray(options))
33
+ throw new TypeError("redact must be false or an options object.");
34
+ const keys = options.keys ?? defaultKeys,
35
+ values = options.values ?? [],
36
+ replacement = options.replacement ?? "[REDACTED]";
37
+ for (const [name, list] of [
38
+ ["keys", keys],
39
+ ["values", values],
40
+ ])
41
+ if (
42
+ !Array.isArray(list) ||
43
+ list.length > 100 ||
44
+ !list.every((value) => typeof value === "string" && value.length > 0)
45
+ )
46
+ throw new TypeError(
47
+ `Redaction ${name} must contain at most 100 nonempty strings.`,
48
+ );
49
+ if (typeof replacement !== "string")
50
+ throw new TypeError("Redaction replacement must be a string.");
51
+ const protectedKeys = new Set(keys.map(normalize));
52
+ // Longest first prevents a short secret from exposing a suffix of a longer one.
53
+ const literals = [...new Set(values)].sort((a, b) => b.length - a.length);
54
+ const text = (value) =>
55
+ literals.reduce(
56
+ (output, secret) => output.split(secret).join(replacement),
57
+ value,
58
+ );
59
+ return {
60
+ text,
61
+ context: (context) =>
62
+ Object.fromEntries(
63
+ Object.entries(copyContext(context)).map(([key, value]) => [
64
+ key,
65
+ protectedKeys.has(normalize(key))
66
+ ? replacement
67
+ : typeof value === "string"
68
+ ? text(value)
69
+ : value,
70
+ ]),
71
+ ),
72
+ };
73
+ }