@monochromatic-dev/module-logger 0.1.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 +11 -0
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSES/LGPL-3.0-or-later.txt +165 -0
- package/README.md +404 -0
- package/dist/final/neutral/index.d.mts +673 -0
- package/dist/final/neutral/index.mjs +3 -0
- package/dist/final/neutral/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/dist/final/node/index.d.mts +673 -0
- package/dist/final/node/index.mjs +3 -0
- package/dist/final/node/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/package.json +43 -0
- package/src/create-logger.ts +494 -0
- package/src/create-logger.unit.test.ts +752 -0
- package/src/error-format.ts +43 -0
- package/src/index.ts +35 -0
- package/src/logger.ts +67 -0
- package/src/logger.unit.test.ts +190 -0
- package/src/sink/console-control-chars.ts +140 -0
- package/src/sink/console-control-chars.unit.test.ts +206 -0
- package/src/sink/console.ts +531 -0
- package/src/sink/console.unit.test.ts +542 -0
- package/src/sink/file.ts +297 -0
- package/src/sink/file.unit.test.ts +202 -0
- package/src/sink/index.ts +11 -0
- package/src/sink/indexed-db-util.ts +96 -0
- package/src/sink/indexed-db.browser.test.ts +184 -0
- package/src/sink/indexed-db.ts +324 -0
- package/src/sink/indexed-db.unit.test.ts +80 -0
- package/src/sink/local-storage-key.ts +176 -0
- package/src/sink/local-storage-key.unit.test.ts +106 -0
- package/src/sink/local-storage-quota.ts +60 -0
- package/src/sink/local-storage-quota.unit.test.ts +98 -0
- package/src/sink/local-storage-store.ts +368 -0
- package/src/sink/local-storage-store.unit.test.ts +329 -0
- package/src/sink/local-storage.browser.test.ts +125 -0
- package/src/sink/local-storage.ts +182 -0
- package/src/sink/local-storage.unit.test.ts +218 -0
- package/src/sink/noop.ts +46 -0
- package/src/sink/noop.unit.test.ts +47 -0
- package/src/sink/opfs.browser.test.ts +84 -0
- package/src/sink/opfs.ts +212 -0
- package/src/sink/opfs.unit.test.ts +81 -0
- package/src/sink/record-buffer.ts +230 -0
- package/src/sink/record-buffer.unit.test.ts +288 -0
- package/src/sink/session-storage-quota.ts +57 -0
- package/src/sink/session-storage-quota.unit.test.ts +98 -0
- package/src/sink/session-storage-store.ts +178 -0
- package/src/sink/session-storage.browser.test.ts +137 -0
- package/src/sink/session-storage.ts +128 -0
- package/src/sink/session-storage.unit.test.ts +527 -0
- package/src/sink/web-storage-quota-error.ts +43 -0
- package/src/sink/web-storage-quota-error.unit.test.ts +55 -0
- package/src/sink/web-storage-runtime.ts +49 -0
- package/src/startup.unit.test.ts +232 -0
- package/src/tagged.ts +74 -0
- package/src/tagged.unit.test.ts +211 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { spawn, } from 'node:child_process';
|
|
2
|
+
import { once, } from 'node:events';
|
|
3
|
+
import {
|
|
4
|
+
mkdir,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
rm,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from 'node:fs/promises';
|
|
11
|
+
import { tmpdir, } from 'node:os';
|
|
12
|
+
import { join, } from 'node:path';
|
|
13
|
+
import { text, } from 'node:stream/consumers';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
describe,
|
|
17
|
+
expect,
|
|
18
|
+
it,
|
|
19
|
+
} from '@monochromatic-dev/module-test/ts';
|
|
20
|
+
|
|
21
|
+
/** Built package entry used by the subprocess probe. */
|
|
22
|
+
const BUILT_INDEX_PATH = join(
|
|
23
|
+
import.meta.dirname,
|
|
24
|
+
'..',
|
|
25
|
+
'dist',
|
|
26
|
+
'final',
|
|
27
|
+
'node',
|
|
28
|
+
'index.mjs',
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/** Message logged before the probe awaits anything from the logger package. */
|
|
32
|
+
const STARTUP_MESSAGE = 'startup before consumer init await';
|
|
33
|
+
|
|
34
|
+
/** JSONL fragment proving the startup message reached the file sink. */
|
|
35
|
+
const STARTUP_MESSAGE_FRAGMENT = `"message":${JSON.stringify(STARTUP_MESSAGE,)}`;
|
|
36
|
+
|
|
37
|
+
/** Debug message used by the process-stream startup probe. */
|
|
38
|
+
const DEBUG_MESSAGE = 'debug before consumer init await';
|
|
39
|
+
|
|
40
|
+
/** Async-disposable temporary project for file-sink startup probes. */
|
|
41
|
+
type TempProject = {
|
|
42
|
+
readonly path: string;
|
|
43
|
+
readonly scriptPath: string;
|
|
44
|
+
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Result captured from the probe subprocess. */
|
|
48
|
+
type ProbeResult = {
|
|
49
|
+
readonly exitCode: number;
|
|
50
|
+
readonly stderr: string;
|
|
51
|
+
readonly stdout: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Builds a throwaway project root with `node_modules` so the file sink chooses
|
|
56
|
+
* an isolated `node_modules/.monochromatic` log directory.
|
|
57
|
+
*
|
|
58
|
+
* @param logLine - Logger call that the generated probe should execute.
|
|
59
|
+
*
|
|
60
|
+
* @returns Temporary project handle removed by `await using`.
|
|
61
|
+
*/
|
|
62
|
+
async function createTempProject(
|
|
63
|
+
{ logLine, }: { readonly logLine: string; },
|
|
64
|
+
): Promise<TempProject> {
|
|
65
|
+
const path = await mkdtemp(join(tmpdir(), 'logger-startup-',),);
|
|
66
|
+
await mkdir(
|
|
67
|
+
join(
|
|
68
|
+
path,
|
|
69
|
+
'node_modules',
|
|
70
|
+
),
|
|
71
|
+
{ recursive: true, },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
/** Probe script that never imports or awaits `initPromise`. */
|
|
75
|
+
const scriptPath = join(
|
|
76
|
+
path,
|
|
77
|
+
'probe.ts',
|
|
78
|
+
);
|
|
79
|
+
/** Probe source assembled as separate lines so generated syntax stays readable. */
|
|
80
|
+
const script = [
|
|
81
|
+
`import { logger, } from ${JSON.stringify(BUILT_INDEX_PATH,)};`,
|
|
82
|
+
'',
|
|
83
|
+
logLine,
|
|
84
|
+
'await logger.flush();',
|
|
85
|
+
'',
|
|
86
|
+
].join('\n',);
|
|
87
|
+
await writeFile(
|
|
88
|
+
scriptPath,
|
|
89
|
+
script,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
path,
|
|
94
|
+
scriptPath,
|
|
95
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
96
|
+
await rm(path, { recursive: true, force: true, },);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Runs a probe script in its temporary project root.
|
|
103
|
+
*
|
|
104
|
+
* @param cwd - Project root used as `process.cwd()` by the file sink.
|
|
105
|
+
* @param env - Environment overrides applied to the probe process.
|
|
106
|
+
* @param scriptPath - Absolute path to the probe script.
|
|
107
|
+
*
|
|
108
|
+
* @returns Captured stdout, stderr, and exit code.
|
|
109
|
+
*/
|
|
110
|
+
async function runProbe(
|
|
111
|
+
{
|
|
112
|
+
cwd,
|
|
113
|
+
env = {},
|
|
114
|
+
scriptPath,
|
|
115
|
+
}: {
|
|
116
|
+
readonly cwd: string;
|
|
117
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
118
|
+
readonly scriptPath: string;
|
|
119
|
+
},
|
|
120
|
+
): Promise<ProbeResult> {
|
|
121
|
+
// Default stdio is 'pipe', which yields a ChildProcessWithoutNullStreams so
|
|
122
|
+
// stdout/stderr are non-null Readables. The probe is a `.ts` file, so it runs
|
|
123
|
+
// through the same Node runtime used by mise test tasks.
|
|
124
|
+
const subprocess = spawn(
|
|
125
|
+
'node',
|
|
126
|
+
[scriptPath,],
|
|
127
|
+
{
|
|
128
|
+
cwd,
|
|
129
|
+
env: {
|
|
130
|
+
...process.env,
|
|
131
|
+
...env,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const [
|
|
137
|
+
stdout,
|
|
138
|
+
stderr,
|
|
139
|
+
] = await Promise.all([
|
|
140
|
+
text(subprocess.stdout,),
|
|
141
|
+
text(subprocess.stderr,),
|
|
142
|
+
once(subprocess, 'close',),
|
|
143
|
+
],);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
// After 'close' the exit code is populated; null means killed by signal.
|
|
147
|
+
exitCode: subprocess.exitCode ?? (-1),
|
|
148
|
+
stderr,
|
|
149
|
+
stdout,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Reads the single JSONL file created by the probe's file sink.
|
|
155
|
+
*
|
|
156
|
+
* @param projectPath - Temporary project root containing `node_modules`.
|
|
157
|
+
*
|
|
158
|
+
* @returns Log file contents.
|
|
159
|
+
*/
|
|
160
|
+
async function readOnlyLogContent({ projectPath, }: { readonly projectPath: string; },): Promise<string> {
|
|
161
|
+
const logDir = join(
|
|
162
|
+
projectPath,
|
|
163
|
+
'node_modules',
|
|
164
|
+
'.monochromatic',
|
|
165
|
+
);
|
|
166
|
+
const logFiles = await readdir(logDir,);
|
|
167
|
+
expect(logFiles.length,)
|
|
168
|
+
.toBe(1,);
|
|
169
|
+
|
|
170
|
+
/** Single log file emitted by one probe process. */
|
|
171
|
+
const [logFile,] = logFiles;
|
|
172
|
+
if (logFile === undefined)
|
|
173
|
+
throw new Error('Expected logger to create a log file.',);
|
|
174
|
+
|
|
175
|
+
return await readFile(
|
|
176
|
+
join(
|
|
177
|
+
logDir,
|
|
178
|
+
logFile,
|
|
179
|
+
),
|
|
180
|
+
'utf8',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await describe({
|
|
185
|
+
name: 'logger startup initialization',
|
|
186
|
+
children: [
|
|
187
|
+
it({
|
|
188
|
+
name: 'delivers startup records to file sink without awaiting initPromise',
|
|
189
|
+
fn: async () => {
|
|
190
|
+
await using project = await createTempProject({
|
|
191
|
+
logLine: `logger.info(${JSON.stringify(STARTUP_MESSAGE,)},);`,
|
|
192
|
+
},);
|
|
193
|
+
|
|
194
|
+
const result = await runProbe({
|
|
195
|
+
cwd: project.path,
|
|
196
|
+
scriptPath: project.scriptPath,
|
|
197
|
+
},);
|
|
198
|
+
expect(result.exitCode,)
|
|
199
|
+
.toBe(0,);
|
|
200
|
+
expect(result.stderr,)
|
|
201
|
+
.toBe('',);
|
|
202
|
+
expect(result.stdout,)
|
|
203
|
+
.toContain(STARTUP_MESSAGE,);
|
|
204
|
+
|
|
205
|
+
const content = await readOnlyLogContent({ projectPath: project.path, },);
|
|
206
|
+
expect(content,)
|
|
207
|
+
.toContain(STARTUP_MESSAGE_FRAGMENT,);
|
|
208
|
+
},
|
|
209
|
+
},),
|
|
210
|
+
|
|
211
|
+
it({
|
|
212
|
+
name: 'writes debug startup records to stderr when process stderr is available',
|
|
213
|
+
fn: async () => {
|
|
214
|
+
await using project = await createTempProject({
|
|
215
|
+
logLine: `logger.debug(${JSON.stringify(DEBUG_MESSAGE,)},);`,
|
|
216
|
+
},);
|
|
217
|
+
|
|
218
|
+
const result = await runProbe({
|
|
219
|
+
cwd: project.path,
|
|
220
|
+
env: { MONOCHROMATIC_VERBOSE: 'true', },
|
|
221
|
+
scriptPath: project.scriptPath,
|
|
222
|
+
},);
|
|
223
|
+
expect(result.exitCode,)
|
|
224
|
+
.toBe(0,);
|
|
225
|
+
expect(result.stdout,)
|
|
226
|
+
.toBe('',);
|
|
227
|
+
expect(result.stderr,)
|
|
228
|
+
.toContain(DEBUG_MESSAGE,);
|
|
229
|
+
},
|
|
230
|
+
},),
|
|
231
|
+
],
|
|
232
|
+
},);
|
package/src/tagged.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { logger as defaultLogger, } from './logger.ts';
|
|
2
|
+
import type { Logger, } from './types.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wraps a logger so every message is prefixed with `[tag] `.
|
|
6
|
+
* Callers typically pass `myFn.name` as tag to keep prefixes
|
|
7
|
+
* in sync with refactors.
|
|
8
|
+
*
|
|
9
|
+
* @param tag - Prefix string inserted before each message
|
|
10
|
+
*
|
|
11
|
+
* @param l - Base logger to wrap; defaults to the module-level {@link logger}
|
|
12
|
+
* singleton
|
|
13
|
+
*
|
|
14
|
+
* @returns Logger whose methods prepend `[tag] ` to every message
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { tagged } from '\@monochromatic-dev/module-logger/tagged';
|
|
19
|
+
*
|
|
20
|
+
* function handleRequest({ l }: { l: Logger }): void {
|
|
21
|
+
* l.info('received');
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* handleRequest({ l: tagged({ tag: handleRequest.name }) });
|
|
25
|
+
* // logs: [handleRequest] received
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* // Composing tags: the outermost wrap (`l2` here) prepends to the message
|
|
31
|
+
* // last, so its tag ends up rightmost. The innermost wrap (`l1`) hits the
|
|
32
|
+
* // underlying logger first, so its tag is leftmost. The chain reads
|
|
33
|
+
* // root-first: outer wrap = inner tag position.
|
|
34
|
+
* const l1 = tagged({ tag: 'http' });
|
|
35
|
+
* const l2 = tagged({ tag: 'retry', l: l1 });
|
|
36
|
+
* l2.info('attempt 3');
|
|
37
|
+
* // logs: [http] [retry] attempt 3
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function tagged({
|
|
41
|
+
tag,
|
|
42
|
+
l = defaultLogger,
|
|
43
|
+
}: {
|
|
44
|
+
readonly l?: Logger;
|
|
45
|
+
readonly tag: string;
|
|
46
|
+
},): Logger {
|
|
47
|
+
/**
|
|
48
|
+
* Bracketed tag prepended to every message; built once so each log call does one concatenation.
|
|
49
|
+
*/
|
|
50
|
+
const prefix = `[${tag}] `;
|
|
51
|
+
return {
|
|
52
|
+
debug: function debug(message: string,): void {
|
|
53
|
+
l.debug(`${prefix}${message}`,);
|
|
54
|
+
},
|
|
55
|
+
error: function error(message: string,): void {
|
|
56
|
+
l.error(`${prefix}${message}`,);
|
|
57
|
+
},
|
|
58
|
+
fatal: function fatal(message: string,): void {
|
|
59
|
+
l.fatal(`${prefix}${message}`,);
|
|
60
|
+
},
|
|
61
|
+
flush: function flush(): Promise<void> {
|
|
62
|
+
return l.flush();
|
|
63
|
+
},
|
|
64
|
+
info: function info(message: string,): void {
|
|
65
|
+
l.info(`${prefix}${message}`,);
|
|
66
|
+
},
|
|
67
|
+
trace: function trace(message: string,): void {
|
|
68
|
+
l.trace(`${prefix}${message}`,);
|
|
69
|
+
},
|
|
70
|
+
warn: function warn(message: string,): void {
|
|
71
|
+
l.warn(`${prefix}${message}`,);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from '@monochromatic-dev/module-test/ts';
|
|
6
|
+
import {
|
|
7
|
+
tagged,
|
|
8
|
+
type Level,
|
|
9
|
+
type Logger,
|
|
10
|
+
} from '@monochromatic-dev/module-logger';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every log level paired with the message body the all-levels test sends
|
|
14
|
+
* through each, so the assertion can be built from one source of truth.
|
|
15
|
+
*/
|
|
16
|
+
const LEVEL_MESSAGES: readonly { readonly level: Level; readonly message: string; }[] = [
|
|
17
|
+
{
|
|
18
|
+
level: 'trace',
|
|
19
|
+
message: 'a',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
level: 'debug',
|
|
23
|
+
message: 'b',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
level: 'info',
|
|
27
|
+
message: 'c',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
level: 'warn',
|
|
31
|
+
message: 'd',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
level: 'error',
|
|
35
|
+
message: 'e',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
level: 'fatal',
|
|
39
|
+
message: 'f',
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds a stub logger whose methods record the messages they receive
|
|
45
|
+
* and whose `flush` is a trivial resolved promise. Used to verify the
|
|
46
|
+
* tagged wrapper's message prefixing and `flush` forwarding without
|
|
47
|
+
* touching the default multi-sink singleton.
|
|
48
|
+
*
|
|
49
|
+
* @returns Object containing the stub logger and the recorded message list.
|
|
50
|
+
*/
|
|
51
|
+
function createStubLogger(): {
|
|
52
|
+
l: Logger;
|
|
53
|
+
messages: { level: string; message: string; }[];
|
|
54
|
+
flushCalls: number;
|
|
55
|
+
} {
|
|
56
|
+
const messages: { level: string; message: string; }[] = [];
|
|
57
|
+
const counters: { flushCalls: number; } = { flushCalls: 0, };
|
|
58
|
+
const l: Logger = {
|
|
59
|
+
debug: function debug(message: string,): void {
|
|
60
|
+
messages.push({ level: 'debug', message, },);
|
|
61
|
+
},
|
|
62
|
+
error: function error(message: string,): void {
|
|
63
|
+
messages.push({ level: 'error', message, },);
|
|
64
|
+
},
|
|
65
|
+
fatal: function fatal(message: string,): void {
|
|
66
|
+
messages.push({ level: 'fatal', message, },);
|
|
67
|
+
},
|
|
68
|
+
flush: function flush(): Promise<void> {
|
|
69
|
+
counters.flushCalls++;
|
|
70
|
+
return Promise.resolve();
|
|
71
|
+
},
|
|
72
|
+
info: function info(message: string,): void {
|
|
73
|
+
messages.push({ level: 'info', message, },);
|
|
74
|
+
},
|
|
75
|
+
trace: function trace(message: string,): void {
|
|
76
|
+
messages.push({ level: 'trace', message, },);
|
|
77
|
+
},
|
|
78
|
+
warn: function warn(message: string,): void {
|
|
79
|
+
messages.push({ level: 'warn', message, },);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
get flushCalls() {
|
|
84
|
+
return counters.flushCalls;
|
|
85
|
+
},
|
|
86
|
+
l,
|
|
87
|
+
messages,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await describe({
|
|
92
|
+
name: 'tagged logger wrapper',
|
|
93
|
+
children: [
|
|
94
|
+
it({
|
|
95
|
+
name: 'prepends [tag] to each level method',
|
|
96
|
+
fn: async () => {
|
|
97
|
+
const stub = createStubLogger();
|
|
98
|
+
const wrapped = tagged({
|
|
99
|
+
tag: 'scope',
|
|
100
|
+
l: stub.l,
|
|
101
|
+
},);
|
|
102
|
+
|
|
103
|
+
wrapped.info('hello',);
|
|
104
|
+
wrapped.warn('careful',);
|
|
105
|
+
|
|
106
|
+
expect(stub.messages,)
|
|
107
|
+
.toEqual([
|
|
108
|
+
{ level: 'info', message: '[scope] hello', },
|
|
109
|
+
{ level: 'warn', message: '[scope] careful', },
|
|
110
|
+
],);
|
|
111
|
+
},
|
|
112
|
+
},),
|
|
113
|
+
|
|
114
|
+
it({
|
|
115
|
+
name: 'flush delegates to the inner logger without tagging',
|
|
116
|
+
fn: async () => {
|
|
117
|
+
const stub = createStubLogger();
|
|
118
|
+
const wrapped = tagged({
|
|
119
|
+
tag: 'scope',
|
|
120
|
+
l: stub.l,
|
|
121
|
+
},);
|
|
122
|
+
|
|
123
|
+
expect(stub.flushCalls,)
|
|
124
|
+
.toBe(0,);
|
|
125
|
+
await wrapped.flush();
|
|
126
|
+
expect(stub.flushCalls,)
|
|
127
|
+
.toBe(1,);
|
|
128
|
+
// flush must not leak into the message stream
|
|
129
|
+
expect(stub.messages.length,)
|
|
130
|
+
.toBe(0,);
|
|
131
|
+
},
|
|
132
|
+
},),
|
|
133
|
+
|
|
134
|
+
it({
|
|
135
|
+
name: 'prepends [tag] across all six level methods',
|
|
136
|
+
fn: async () => {
|
|
137
|
+
const stub = createStubLogger();
|
|
138
|
+
const wrapped = tagged({
|
|
139
|
+
tag: 'svc',
|
|
140
|
+
l: stub.l,
|
|
141
|
+
},);
|
|
142
|
+
|
|
143
|
+
LEVEL_MESSAGES.forEach(function logAtLevel({
|
|
144
|
+
level,
|
|
145
|
+
message,
|
|
146
|
+
},) {
|
|
147
|
+
wrapped[level](message,);
|
|
148
|
+
},);
|
|
149
|
+
|
|
150
|
+
expect(stub.messages,)
|
|
151
|
+
.toEqual(LEVEL_MESSAGES.map(function toTagged({
|
|
152
|
+
level,
|
|
153
|
+
message,
|
|
154
|
+
},) {
|
|
155
|
+
return {
|
|
156
|
+
level,
|
|
157
|
+
message: `[svc] ${message}`,
|
|
158
|
+
};
|
|
159
|
+
},),);
|
|
160
|
+
},
|
|
161
|
+
},),
|
|
162
|
+
|
|
163
|
+
it({
|
|
164
|
+
name: 'composes nested tags root-first',
|
|
165
|
+
fn: async () => {
|
|
166
|
+
const stub = createStubLogger();
|
|
167
|
+
// Inner wrap hits the underlying logger first (leftmost tag); outer
|
|
168
|
+
// wrap prepends last (rightmost tag), so the stub sees them root-first.
|
|
169
|
+
const inner = tagged({
|
|
170
|
+
tag: 'http',
|
|
171
|
+
l: stub.l,
|
|
172
|
+
},);
|
|
173
|
+
const outer = tagged({
|
|
174
|
+
tag: 'retry',
|
|
175
|
+
l: inner,
|
|
176
|
+
},);
|
|
177
|
+
|
|
178
|
+
outer.info('attempt 3',);
|
|
179
|
+
|
|
180
|
+
expect(stub.messages,)
|
|
181
|
+
.toEqual([
|
|
182
|
+
{
|
|
183
|
+
level: 'info',
|
|
184
|
+
message: '[http] [retry] attempt 3',
|
|
185
|
+
},
|
|
186
|
+
],);
|
|
187
|
+
},
|
|
188
|
+
},),
|
|
189
|
+
|
|
190
|
+
it({
|
|
191
|
+
name: 'wraps the default singleton logger when no parent is given',
|
|
192
|
+
fn: async () => {
|
|
193
|
+
// Exercises the `l = defaultLogger` parameter default: a tag with no
|
|
194
|
+
// explicit parent returns a usable wrapper whose calls reach the
|
|
195
|
+
// singleton without throwing and whose flush delegates to it.
|
|
196
|
+
const wrapped = tagged({ tag: 'standalone', },);
|
|
197
|
+
|
|
198
|
+
expect(typeof wrapped.info,)
|
|
199
|
+
.toBe('function',);
|
|
200
|
+
expect(function logThroughDefault() {
|
|
201
|
+
wrapped.info('via default logger',);
|
|
202
|
+
},)
|
|
203
|
+
.not
|
|
204
|
+
.toThrow();
|
|
205
|
+
await expect(wrapped.flush(),)
|
|
206
|
+
.resolves
|
|
207
|
+
.toBeUndefined();
|
|
208
|
+
},
|
|
209
|
+
},),
|
|
210
|
+
],
|
|
211
|
+
},);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log severity levels ordered from least to most severe.
|
|
3
|
+
*/
|
|
4
|
+
export type Level = 'debug' | 'error' | 'fatal' | 'info' | 'trace' | 'warn';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Structured log record written to sinks.
|
|
8
|
+
*/
|
|
9
|
+
export type LogRecord = {
|
|
10
|
+
readonly level: Level;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly timestamp: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Optional drain hook for sinks that buffer records internally.
|
|
17
|
+
* Called via logger-level {@link Logger.flush} to force buffered work
|
|
18
|
+
* through before a process exit, critical error boundary, or assertion.
|
|
19
|
+
*
|
|
20
|
+
* Always async: sinks whose drain is synchronous return an
|
|
21
|
+
* already-resolved promise so callers `await` uniformly. A `void` arm is
|
|
22
|
+
* not used; under the `no-optional-escape` rule `T | void` is a banned
|
|
23
|
+
* fake-optional encoding, and there is no real synchronous value to carry.
|
|
24
|
+
*/
|
|
25
|
+
export type SinkFlush = () => Promise<void>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Verification function that checks if a sink backend is available.
|
|
29
|
+
* May run setup side effects (resolving a log path, opening a writable
|
|
30
|
+
* stream) and reports whether the backend is usable. A sink whose
|
|
31
|
+
* verification resolves `false` (or rejects) is dropped by the logger and
|
|
32
|
+
* receives no further records.
|
|
33
|
+
*
|
|
34
|
+
* Always async, matching `write` and `flush`: a synchronous check returns an
|
|
35
|
+
* already-resolved promise (`Promise.resolve(check)`) so the logger awaits
|
|
36
|
+
* verification uniformly with no sync/async branch.
|
|
37
|
+
*/
|
|
38
|
+
export type Verify = () => Promise<boolean>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sink that receives log records. A sink is a self-describing adapter: it
|
|
42
|
+
* carries everything the logger must know to use it, namely how to
|
|
43
|
+
* `verify` its backend is available, how to `write` a record, and
|
|
44
|
+
* optionally how to `flush` buffered work. Holding `verify` on the sink
|
|
45
|
+
* (rather than as a sibling export the logger pairs by hand) lets the
|
|
46
|
+
* logger treat a registry as a plain `Sink[]` and lets a test supply one
|
|
47
|
+
* self-contained fake.
|
|
48
|
+
*
|
|
49
|
+
* Sinks that buffer records (e.g. microtask-batched console) may
|
|
50
|
+
* expose a `flush` hook so callers can force emission on demand.
|
|
51
|
+
*
|
|
52
|
+
* `write` is always async: a synchronous sink does its work eagerly and
|
|
53
|
+
* returns an already-resolved promise, so the logger observes a uniform
|
|
54
|
+
* `Promise<void>`. A rejected write is handled per sink and does not
|
|
55
|
+
* disable the backend; only a failed `verify` drops a sink. A `void` arm
|
|
56
|
+
* is not used, for the reason stated on {@link SinkFlush}.
|
|
57
|
+
*/
|
|
58
|
+
export type Sink = {
|
|
59
|
+
readonly flush?: SinkFlush;
|
|
60
|
+
readonly verify: Verify;
|
|
61
|
+
readonly write: (record: LogRecord,) => Promise<void>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Logger interface with 6 log levels plus `flush` for startup and sink drains.
|
|
66
|
+
* `flush()` resolves once startup verification has completed, tracked sink
|
|
67
|
+
* writes have settled, and every available sink's own {@link SinkFlush} hook
|
|
68
|
+
* has settled. Safe to call even when no sink buffers.
|
|
69
|
+
*/
|
|
70
|
+
export type Logger = {
|
|
71
|
+
readonly debug: (message: string,) => void;
|
|
72
|
+
readonly error: (message: string,) => void;
|
|
73
|
+
readonly fatal: (message: string,) => void;
|
|
74
|
+
readonly flush: () => Promise<void>;
|
|
75
|
+
readonly info: (message: string,) => void;
|
|
76
|
+
readonly trace: (message: string,) => void;
|
|
77
|
+
readonly warn: (message: string,) => void;
|
|
78
|
+
};
|