Skip to content

Commit d1973a2

Browse files
grypezclaude
andcommitted
refactor(logger): add tagless console and file transports
Add `ConsoleTransport` and `FileTransport` classes with a tagless architecture for flexible log routing. Console transport wraps `globalThis.console`, file transport appends to a file via `node:fs`. Both are exported from `@metamask/logger/transports`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cafa17c commit d1973a2

File tree

7 files changed

+181
-0
lines changed

7 files changed

+181
-0
lines changed

packages/logger/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@
2929
"default": "./dist/index.cjs"
3030
}
3131
},
32+
"./file-transport": {
33+
"import": {
34+
"types": "./dist/file-transport.d.mts",
35+
"default": "./dist/file-transport.mjs"
36+
},
37+
"require": {
38+
"types": "./dist/file-transport.d.cts",
39+
"default": "./dist/file-transport.cjs"
40+
}
41+
},
3242
"./package.json": "./package.json"
3343
},
3444
"main": "./dist/index.cjs",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { readFile, rm } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
6+
import { makeFileTransport } from './file-transport.ts';
7+
import type { LogEntry } from './types.ts';
8+
9+
const makeLogEntry = (overrides?: Partial<LogEntry>): LogEntry => ({
10+
level: 'info',
11+
message: 'test-message',
12+
tags: ['test-tag'],
13+
...overrides,
14+
});
15+
16+
describe('makeFileTransport', () => {
17+
const testDir = join(tmpdir(), 'logger-file-transport-test');
18+
19+
afterEach(async () => {
20+
await rm(testDir, { recursive: true, force: true });
21+
});
22+
23+
it('appends a formatted log line to the file', async () => {
24+
const filePath = join(testDir, 'test.log');
25+
const transport = makeFileTransport(filePath);
26+
const entry = makeLogEntry();
27+
28+
transport(entry);
29+
// Wait for the async write to complete
30+
await vi.waitFor(async () => {
31+
const content = await readFile(filePath, 'utf-8');
32+
expect(content).toContain('[info] [test-tag] test-message');
33+
});
34+
});
35+
36+
it('creates parent directories', async () => {
37+
const filePath = join(testDir, 'nested', 'deep', 'test.log');
38+
const transport = makeFileTransport(filePath);
39+
40+
transport(makeLogEntry());
41+
await vi.waitFor(async () => {
42+
const content = await readFile(filePath, 'utf-8');
43+
expect(content).toContain('test-message');
44+
});
45+
});
46+
47+
it('omits tag prefix when tags are empty', async () => {
48+
const filePath = join(testDir, 'no-tags.log');
49+
const transport = makeFileTransport(filePath);
50+
51+
transport(makeLogEntry({ tags: [] }));
52+
await vi.waitFor(async () => {
53+
const content = await readFile(filePath, 'utf-8');
54+
expect(content).toMatch(/\[info\] test-message/u);
55+
expect(content).not.toContain('[]');
56+
});
57+
});
58+
59+
it('includes data in the log line', async () => {
60+
const filePath = join(testDir, 'data.log');
61+
const transport = makeFileTransport(filePath);
62+
63+
transport(makeLogEntry({ data: ['extra-data'] }));
64+
await vi.waitFor(async () => {
65+
const content = await readFile(filePath, 'utf-8');
66+
expect(content).toContain('test-message extra-data');
67+
});
68+
});
69+
70+
it('silently handles write errors', async () => {
71+
// Use an invalid path to trigger an error
72+
const transport = makeFileTransport('/dev/null/impossible/test.log');
73+
// Should not throw
74+
expect(() => transport(makeLogEntry())).not.toThrow();
75+
});
76+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { appendFile, mkdir } from 'node:fs/promises';
2+
import { dirname } from 'node:path';
3+
4+
import type { Transport } from './types.ts';
5+
6+
/**
7+
* Creates a file transport that appends timestamped log lines to a file.
8+
* Parent directories are created automatically. Tags are included in the
9+
* file output for structured log analysis.
10+
*
11+
* This transport requires Node.js (`node:fs/promises`).
12+
*
13+
* @param filePath - Absolute path to the log file.
14+
* @returns A transport function that appends to the file.
15+
*/
16+
export function makeFileTransport(filePath: string): Transport {
17+
return (entry) => {
18+
const tags = entry.tags.length > 0 ? `[${entry.tags.join(', ')}] ` : '';
19+
const parts = [
20+
...(entry.message ? [entry.message] : []),
21+
...(entry.data ?? []),
22+
];
23+
const line = `${new Date().toISOString()} [${entry.level}] ${tags}${parts.join(' ')}\n`;
24+
mkdir(dirname(filePath), { recursive: true })
25+
.then(async () => appendFile(filePath, line))
26+
.catch(() => undefined);
27+
};
28+
}

packages/logger/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { Logger } from './logger.ts';
22
export {
33
makeConsoleTransport,
4+
makeTaglessConsoleTransport,
45
makeArrayTransport,
56
makeStreamTransport,
67
} from './transports.ts';

packages/logger/src/transports.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest';
55
import { logLevels } from './constants.ts';
66
import {
77
makeConsoleTransport,
8+
makeTaglessConsoleTransport,
89
makeArrayTransport,
910
makeStreamTransport,
1011
} from './transports.ts';
@@ -33,6 +34,33 @@ describe('consoleTransport', () => {
3334
);
3435
});
3536

37+
describe('taglessConsoleTransport', () => {
38+
it.each(Object.keys(logLevels))(
39+
'logs without tags for level: %s',
40+
(levelString: string) => {
41+
const transport = makeTaglessConsoleTransport();
42+
const level = levelString as LogLevel;
43+
const logEntry = makeLogEntry(level);
44+
const consoleMethodSpy = vi.spyOn(console, level);
45+
transport(logEntry);
46+
expect(consoleMethodSpy).toHaveBeenCalledWith(logEntry.message);
47+
},
48+
);
49+
50+
it('omits tags from output', () => {
51+
const transport = makeTaglessConsoleTransport();
52+
const entry: LogEntry = {
53+
level: 'info',
54+
message: 'hello',
55+
tags: ['cli', 'daemon'],
56+
data: ['extra'],
57+
};
58+
const spy = vi.spyOn(console, 'info');
59+
transport(entry);
60+
expect(spy).toHaveBeenCalledWith('hello', 'extra');
61+
});
62+
});
63+
3664
describe('makeStreamTransport', () => {
3765
it('writes to the stream', () => {
3866
const logLevel = 'info';

packages/logger/src/transports.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,43 @@ export function makeConsoleTransport(level: LogLevel = 'debug'): Transport {
4242
return consoleTransport;
4343
}
4444

45+
/**
46+
* A console transport that omits tags from output. Useful for CLI tools
47+
* where the tag prefix (e.g. `['cli']`) is noise in terminal output.
48+
*
49+
* @param level - The logging level for this instance.
50+
* @returns A transport function that writes to the console without tags.
51+
*/
52+
export function makeTaglessConsoleTransport(
53+
level: LogLevel = 'debug',
54+
): Transport {
55+
const baseLevelIdx = logLevels[level];
56+
const logFn = (method: LogLevel): LogMethod => {
57+
if (baseLevelIdx <= logLevels[method]) {
58+
return (...args: unknown[]) => {
59+
// eslint-disable-next-line no-console
60+
console[method](...args);
61+
};
62+
}
63+
// eslint-disable-next-line no-empty-function
64+
return harden(() => {}) as LogMethod;
65+
};
66+
const filteredConsole = {
67+
debug: logFn('debug'),
68+
info: logFn('info'),
69+
log: logFn('log'),
70+
warn: logFn('warn'),
71+
error: logFn('error'),
72+
};
73+
return (entry) => {
74+
const args = [
75+
...(entry.message ? [entry.message] : []),
76+
...(entry.data ?? []),
77+
] as LogArgs;
78+
filteredConsole[entry.level](...args);
79+
};
80+
}
81+
4582
/**
4683
* The stream transport for the logger. Expects the stream is listening for
4784
* log entries.

tsconfig.packages.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@metamask/kernel-store/sqlite/wasm": [
1616
"../kernel-store/src/sqlite/wasm.ts"
1717
],
18+
"@metamask/logger/file-transport": ["../logger/src/file-transport.ts"],
1819
"@ocap/*": ["../*/src"]
1920
}
2021
}

0 commit comments

Comments
 (0)