@deployanyway/doggo-log 0.4.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,11 @@
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
+
3
9
  ## 0.4.0
4
10
 
5
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.
package/MIGRATION.md CHANGED
@@ -9,3 +9,9 @@ Install 0.3.0 with npm. Seeds and exact humorous wording are version-specific. D
9
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
10
 
11
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,5 +1,43 @@
1
1
  # doggo-log
2
2
 
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
+
3
41
  ## Commentary that stays useful (0.4.0)
4
42
 
5
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.
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
@@ -115,6 +115,63 @@ function commentaryIndex(seed, salt, length) {
115
115
  return hash % length;
116
116
  }
117
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
+
118
175
  // src/index.js
119
176
  function createDogLogger(options = {}) {
120
177
  if (!options || typeof options !== "object" || Array.isArray(options))
@@ -131,6 +188,7 @@ function createDogLogger(options = {}) {
131
188
  prefix: "",
132
189
  level: "info",
133
190
  context: {},
191
+ contextProvider: () => ({}),
134
192
  write: (line, level) => level === "warn" || level === "error" ? console.error(line) : console.log(line),
135
193
  clock: () => /* @__PURE__ */ new Date(),
136
194
  ...options
@@ -148,7 +206,16 @@ function createDogLogger(options = {}) {
148
206
  throw new TypeError(
149
207
  "Context values must be strings, finite numbers, booleans or null."
150
208
  );
151
- 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
+ };
152
219
  if (typeof config.level !== "string" || !Object.hasOwn(levels, config.level))
153
220
  throw new RangeError(
154
221
  `level must be one of: ${Object.keys(levels).join(", ")}.`
@@ -166,7 +233,11 @@ function createDogLogger(options = {}) {
166
233
  (...args) => {
167
234
  if (config.quiet || style.rank < levels[config.level].rank)
168
235
  return void 0;
169
- const message = (0, import_node_util.format)(...args);
236
+ const message = redactor.text((0, import_node_util.format)(...args));
237
+ const context = redactor.context({
238
+ ...config.context,
239
+ ...copyContext(config.contextProvider())
240
+ });
170
241
  const pool = config.bark ? barkLines(level) : void 0;
171
242
  const index = config.barkMode === "rotate" ? (commentaryIndex(
172
243
  config.seed,
@@ -186,18 +257,19 @@ function createDogLogger(options = {}) {
186
257
  line = JSON.stringify({
187
258
  level,
188
259
  message,
189
- ...Object.keys(config.context).length ? { context: { ...config.context } } : {},
260
+ ...Object.keys(context).length ? { context } : {},
190
261
  ...commentary ? { commentary } : {},
191
- ...config.prefix ? { prefix: config.prefix } : {},
262
+ ...config.prefix ? { prefix: redactor.text(config.prefix) } : {},
192
263
  ...timestamp ? { timestamp } : {}
193
264
  });
194
265
  } else {
195
266
  line = [
196
267
  timestamp,
197
- config.prefix,
268
+ redactor.text(config.prefix),
198
269
  config.emoji ? style.emoji : void 0,
199
270
  level.toUpperCase().padEnd(7),
200
271
  message,
272
+ Object.keys(context).length ? JSON.stringify(context) : void 0,
201
273
  commentary
202
274
  ].filter((part) => part !== void 0 && part !== "").join(" ");
203
275
  if (config.color) line = `\x1B[${style.color}m${line}\x1B[0m`;
@@ -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,6 +1,9 @@
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;
6
9
  barkMode?: "classic" | "rotate";
package/index.d.ts CHANGED
@@ -1,6 +1,9 @@
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;
6
9
  barkMode?: "classic" | "rotate";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deployanyway/doggo-log",
3
- "version": "0.4.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,6 +15,8 @@ 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" },
20
22
  "bark-mode": { type: "string" },
@@ -29,7 +31,7 @@ try {
29
31
  });
30
32
  if (values.help) {
31
33
  console.log(
32
- "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 --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.",
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.",
33
35
  );
34
36
  } else if (values.version) {
35
37
  console.log(
@@ -56,6 +58,14 @@ try {
56
58
  !Object.hasOwn(process.env, "NO_COLOR") &&
57
59
  (values.color ?? false),
58
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
+ },
59
69
  timestamp: values.timestamp ?? false,
60
70
  json: values.json ?? false,
61
71
  quiet: values.quiet ?? false,
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,6 +1,7 @@
1
1
  import { format } from "node:util";
2
2
  import { levels } from "./levels.js";
3
3
  import { barkLines, commentaryIndex } from "./commentary.js";
4
+ import { copyContext, createRedactor } from "./redaction.js";
4
5
  export { barkLines };
5
6
 
6
7
  /**
@@ -22,6 +23,7 @@ export function createDogLogger(options = {}) {
22
23
  prefix: "",
23
24
  level: "info",
24
25
  context: {},
26
+ contextProvider: () => ({}),
25
27
  write: (line, level) =>
26
28
  level === "warn" || level === "error"
27
29
  ? console.error(line)
@@ -51,7 +53,16 @@ export function createDogLogger(options = {}) {
51
53
  throw new TypeError(
52
54
  "Context values must be strings, finite numbers, booleans or null.",
53
55
  );
54
- 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
+ };
55
66
  if (typeof config.level !== "string" || !Object.hasOwn(levels, config.level))
56
67
  throw new RangeError(
57
68
  `level must be one of: ${Object.keys(levels).join(", ")}.`,
@@ -73,7 +84,11 @@ export function createDogLogger(options = {}) {
73
84
  (...args) => {
74
85
  if (config.quiet || style.rank < levels[config.level].rank)
75
86
  return undefined;
76
- const message = format(...args);
87
+ const message = redactor.text(format(...args));
88
+ const context = redactor.context({
89
+ ...config.context,
90
+ ...copyContext(config.contextProvider()),
91
+ });
77
92
  const pool = config.bark ? barkLines(level) : undefined;
78
93
  const index =
79
94
  config.barkMode === "rotate"
@@ -98,20 +113,19 @@ export function createDogLogger(options = {}) {
98
113
  line = JSON.stringify({
99
114
  level,
100
115
  message,
101
- ...(Object.keys(config.context).length
102
- ? { context: { ...config.context } }
103
- : {}),
116
+ ...(Object.keys(context).length ? { context } : {}),
104
117
  ...(commentary ? { commentary } : {}),
105
- ...(config.prefix ? { prefix: config.prefix } : {}),
118
+ ...(config.prefix ? { prefix: redactor.text(config.prefix) } : {}),
106
119
  ...(timestamp ? { timestamp } : {}),
107
120
  });
108
121
  } else {
109
122
  line = [
110
123
  timestamp,
111
- config.prefix,
124
+ redactor.text(config.prefix),
112
125
  config.emoji ? style.emoji : undefined,
113
126
  level.toUpperCase().padEnd(7),
114
127
  message,
128
+ Object.keys(context).length ? JSON.stringify(context) : undefined,
115
129
  commentary,
116
130
  ]
117
131
  .filter((part) => part !== undefined && part !== "")
@@ -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
+ }