@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 +14 -0
- package/MIGRATION.md +12 -0
- package/README.md +64 -1
- package/context.d.cts +14 -0
- package/context.d.ts +14 -0
- package/dist/context.cjs +340 -0
- package/dist/index.cjs +173 -14
- package/examples/basic.js +7 -0
- package/examples/fetch-context.mjs +55 -0
- package/index.d.cts +7 -0
- package/index.d.ts +7 -0
- package/package.json +20 -4
- package/src/cli.js +15 -1
- package/src/commentary.js +77 -0
- package/src/context.js +33 -0
- package/src/index.js +48 -16
- package/src/redaction.js +73 -0
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
|
-
|
|
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
|
[](https://www.npmjs.com/package/@deployanyway/doggo-log)
|
|
6
69
|
[](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;
|
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
|
@@ -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/
|
|
40
|
-
var
|
|
41
|
-
debug:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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.
|
|
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,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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
]),
|
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
|
+
}
|