@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 +6 -0
- package/MIGRATION.md +6 -0
- package/README.md +38 -0
- package/context.d.cts +14 -0
- package/context.d.ts +14 -0
- package/dist/context.cjs +340 -0
- package/dist/index.cjs +77 -5
- package/examples/basic.js +7 -0
- package/examples/fetch-context.mjs +55 -0
- package/index.d.cts +3 -0
- package/index.d.ts +3 -0
- package/package.json +20 -4
- package/src/cli.js +11 -1
- package/src/context.js +33 -0
- package/src/index.js +21 -7
- package/src/redaction.js +73 -0
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;
|
package/dist/context.cjs
ADDED
|
@@ -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 =
|
|
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(
|
|
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
|
-
"description": "
|
|
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 =
|
|
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(
|
|
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 !== "")
|
package/src/redaction.js
ADDED
|
@@ -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
|
+
}
|