Skip to content

Commit 9808e60

Browse files
committed
feat: logger transporters
1 parent c462d8e commit 9808e60

File tree

5 files changed

+229
-0
lines changed

5 files changed

+229
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { LogLevel, type LogMessage } from '../../../../common/logger/types.js';
3+
import type { LogFormatterMock } from '../../__mocks__/log-formatter.mock.js';
4+
import { LogFormatterMockImpl } from '../../__mocks__/log-formatter.mock.js';
5+
import { ConsoleLogTransporterImpl } from '../console.transporter.js';
6+
7+
describe('console-log-transporter', () => {
8+
let mockLogMessage: LogMessage;
9+
let mockFormatter: LogFormatterMock;
10+
11+
let transporter: ConsoleLogTransporterImpl;
12+
13+
beforeEach(() => {
14+
mockLogMessage = {
15+
level: LogLevel.INFO,
16+
scope: 'test-scope',
17+
message: 'test-message',
18+
timestamp: new Date(),
19+
data: {},
20+
};
21+
22+
mockFormatter = new LogFormatterMockImpl();
23+
24+
transporter = new ConsoleLogTransporterImpl({
25+
formatter: mockFormatter,
26+
});
27+
});
28+
29+
afterEach(() => {
30+
vi.resetAllMocks();
31+
});
32+
33+
describe('#transport', () => {
34+
it('should log the message to the console', () => {
35+
const consoleInfoSpy = vi.spyOn(console, 'info');
36+
37+
transporter.transport(mockLogMessage);
38+
39+
expect(consoleInfoSpy).toHaveBeenCalledWith(mockLogMessage);
40+
});
41+
42+
it('should log the formatted message to the console', () => {
43+
mockFormatter.format.mockReturnValue('test-formatted-message');
44+
45+
const consoleInfoSpy = vi.spyOn(console, 'info');
46+
47+
transporter.transport(mockLogMessage);
48+
49+
expect(consoleInfoSpy).toHaveBeenCalledWith('test-formatted-message');
50+
});
51+
});
52+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "../../../tsconfig.test.json"
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type {
2+
LogMessage,
3+
LogTransporter,
4+
} from '../../../common/logger/types.js';
5+
import { LogLevel } from '../../../common/logger/types.js';
6+
import type { LogFormatter } from '../types.js';
7+
8+
/**
9+
* Transports logs to the console.
10+
*/
11+
export class ConsoleLogTransporterImpl implements LogTransporter {
12+
private formatter?: LogFormatter;
13+
14+
constructor(options?: { formatter?: LogFormatter }) {
15+
this.formatter = options?.formatter;
16+
}
17+
18+
public transport(message: LogMessage): void {
19+
const formattedMessage = this.formatter?.format(message) ?? message;
20+
21+
switch (message.level) {
22+
case LogLevel.ERROR:
23+
console.error(formattedMessage);
24+
break;
25+
case LogLevel.WARN:
26+
console.warn(formattedMessage);
27+
break;
28+
case LogLevel.INFO:
29+
console.info(formattedMessage);
30+
break;
31+
case LogLevel.DEBUG:
32+
case LogLevel.TRACE:
33+
console.debug(formattedMessage);
34+
break;
35+
default:
36+
console.log(formattedMessage);
37+
}
38+
}
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import fs from 'fs-extra';
2+
import type { LogFormatter } from '../types.js';
3+
import { WritableLogTransporterImpl } from './writable.transporter.js';
4+
5+
/**
6+
* Transports logs to a text file.
7+
*/
8+
export class FileLogTransporterImpl extends WritableLogTransporterImpl {
9+
constructor(options: {
10+
/**
11+
* Path where to write the logs.
12+
*/
13+
filePath: string;
14+
/**
15+
* If true, the log messages will be appended to the file.
16+
* If false, the file will be overwritten.
17+
* Default is true.
18+
*/
19+
append?: boolean;
20+
/**
21+
* The text encoding to use when writing the file.
22+
* Default is 'utf8'.
23+
*/
24+
encoding?: BufferEncoding;
25+
/**
26+
* Optional to format the log messages before writing them.
27+
*/
28+
formatter?: LogFormatter;
29+
}) {
30+
super({
31+
writable: fs.createWriteStream(options.filePath, {
32+
flags: options.append ? 'a' : 'w',
33+
encoding: options.encoding ?? 'utf8',
34+
}),
35+
...options,
36+
});
37+
}
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { nextTick } from 'node:process';
2+
import type { Writable } from 'node:stream';
3+
import type {
4+
LogMessage,
5+
LogTransporter,
6+
} from '../../../common/logger/types.js';
7+
import type { LogFormatter } from '../types.js';
8+
9+
/**
10+
* Transports logs via a {@link Writable} stream.
11+
*/
12+
export class WritableLogTransporterImpl implements LogTransporter {
13+
// To allow for asynchronous writing of logs without the use of promises,
14+
// we use a recursive-like queue draining algorithm. This provides an
15+
// ergonomic API while still allowing for asynchronous writing.
16+
// This also allows us to buffer when we need to wait for the writer to drain.
17+
private asyncWriteQueue = new Array<LogMessage>();
18+
private asyncWriteInProgress = false;
19+
20+
private writer: Writable;
21+
private formatter?: LogFormatter;
22+
23+
constructor(options: {
24+
/**
25+
* Where to write the log messages.
26+
*/
27+
writable: Writable;
28+
/**
29+
* Optional to format the log messages before writing them.
30+
*/
31+
formatter?: LogFormatter;
32+
}) {
33+
this.formatter = options?.formatter;
34+
this.writer = options.writable;
35+
36+
this.writer.on('error', (error) => {
37+
this.callback(error);
38+
});
39+
}
40+
41+
public transport(message: LogMessage): void {
42+
// We queue messages to write then process them in the background
43+
// to allow for asynchronous writing of logs without the use of promises
44+
// so that the use of our logger doesn't require 'await' everywhere.
45+
// And to buffer when we need to wait for the writer to drain.
46+
this.asyncWriteQueue.push(message);
47+
48+
// Ensure the log is written asynchronously, but soonish.
49+
nextTick(() => {
50+
this.writeNextMessageAsync();
51+
});
52+
}
53+
54+
protected writeNextMessageAsync(): void {
55+
if (this.asyncWriteInProgress || this.asyncWriteQueue.length === 0) {
56+
return;
57+
}
58+
59+
const message = this.asyncWriteQueue.shift();
60+
if (!message) {
61+
this.callback();
62+
return;
63+
}
64+
65+
this.asyncWriteInProgress = true;
66+
67+
try {
68+
const formattedMessage = this.formatter?.format(message) ?? message;
69+
70+
const doDrain = !this.writer.write(formattedMessage);
71+
72+
if (doDrain) {
73+
this.writer.once('drain', () => {
74+
this.callback();
75+
});
76+
} else {
77+
this.callback();
78+
}
79+
} catch (error) {
80+
this.callback(error);
81+
}
82+
}
83+
84+
protected callback(error?: Error | null): void {
85+
if (error) {
86+
console.error('[LOGGER:WRITE:ERROR]', error);
87+
}
88+
89+
this.asyncWriteInProgress = false;
90+
91+
// Now check for more messages to write.
92+
// Using `nextTick` avoids stack overflow when the queue is large.
93+
nextTick(() => {
94+
this.writeNextMessageAsync();
95+
});
96+
}
97+
}

0 commit comments

Comments
 (0)