metalog 3.1.17 → 4.0.0-prerelease

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.
Files changed (4) hide show
  1. package/README.md +335 -5
  2. package/metalog.d.ts +70 -43
  3. package/metalog.js +302 -220
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -14,21 +14,351 @@
14
14
  ## Usage
15
15
 
16
16
  ```js
17
- const logger = await metalog.openLog({
17
+ const logger = await Logger.create({
18
18
  path: './log', // absolute or relative path
19
19
  workerId: 7, // mark for process or thread
20
- writeInterval: 3000, // flush log to disk interval
21
- writeBuffer: 64 * 1024, // buffer size (default 64kb)
22
- keepDays: 5, // delete after N days, 0 - disable
20
+ flushInterval: 3000, // flush log to disk interval (default: 3s)
21
+ writeBuffer: 64 * 1024, // buffer size (default: 64kb)
22
+ keepDays: 5, // delete after N days, 0 - disable (default: 1)
23
23
  home: process.cwd(), // remove substring from paths
24
- json: false, // print logs in JSON format, by default false
24
+ json: false, // print logs in JSON format (default: false)
25
+ toFile: ['log', 'info', 'warn', 'error'], // tags to write to file (default: all)
26
+ toStdout: ['log', 'info', 'warn', 'error'], // tags to write to stdout (default: all)
27
+ createStream: () => fs.createWriteStream, // custom stream factory (optional)
28
+ crash: 'flush', // crash handling: 'flush' to flush buffer on exit (optional)
25
29
  });
26
30
 
27
31
  const { console } = logger;
32
+
28
33
  console.log('Test message');
34
+ console.info('Info message');
35
+ console.warn('Warning message');
36
+ console.error('Error message');
37
+ console.debug('Debug message');
38
+
39
+ console.assert(true, 'Assertion passed');
40
+ console.assert(false, 'Assertion failed');
41
+ console.count('counter');
42
+ console.count('counter');
43
+ console.countReset('counter');
44
+
45
+ console.time('operation');
46
+ // ... some operation ...
47
+ console.timeEnd('operation');
48
+ console.timeLog('operation', 'Checkpoint');
49
+
50
+ console.group('Group 1');
51
+ console.log('Nested message');
52
+ console.groupCollapsed('Group 2');
53
+ console.log('Collapsed group message');
54
+ console.groupEnd();
55
+ console.groupEnd();
56
+
57
+ console.dir({ key: 'value' });
58
+ console.dirxml('<div>HTML content</div>');
59
+ console.table([
60
+ { name: 'John', age: 30 },
61
+ { name: 'Jane', age: 25 },
62
+ ]);
63
+
64
+ console.trace('Trace message');
65
+
29
66
  await logger.close();
30
67
  ```
31
68
 
69
+ ## Console API Compatibility
70
+
71
+ Metalog provides a fully compatible console implementation that supports all Node.js console methods:
72
+
73
+ - `console.log([data][, ...args])` - General logging
74
+ - `console.info([data][, ...args])` - Informational messages
75
+ - `console.warn([data][, ...args])` - Warning messages
76
+ - `console.error([data][, ...args])` - Error messages
77
+ - `console.debug([data][, ...args])` - Debug messages
78
+ - `console.assert(value[, ...message])` - Assertion testing
79
+ - `console.clear()` - Clear the console
80
+ - `console.count([label])` - Count occurrences
81
+ - `console.countReset([label])` - Reset counter
82
+ - `console.dir(obj[, options])` - Object inspection
83
+ - `console.dirxml(...data)` - XML/HTML inspection
84
+ - `console.group([...label])` - Start group
85
+ - `console.groupCollapsed()` - Start collapsed group
86
+ - `console.groupEnd()` - End group
87
+ - `console.table(tabularData[, properties])` - Table display
88
+ - `console.time([label])` - Start timer
89
+ - `console.timeEnd([label])` - End timer
90
+ - `console.timeLog([label][, ...data])` - Log timer value
91
+ - `console.trace([message][, ...args])` - Stack trace
92
+
93
+ All methods maintain the same behavior as Node.js native console, with output routed through the metalog system for consistent formatting and file logging.
94
+
95
+ ## Configuration Options
96
+
97
+ ### LoggerOptions
98
+
99
+ | Option | Type | Default | Description |
100
+ | --------------- | ---------- | ------------------------------------------- | --------------------------------------------------- |
101
+ | `path` | `string` | **required** | Directory path for log files (absolute or relative) |
102
+ | `home` | `string` | **required** | Base path to remove from stack traces |
103
+ | `workerId` | `number` | `undefined` | Worker/process identifier (appears as W0, W1, etc.) |
104
+ | `flushInterval` | `number` | `3000` | Flush buffer to disk interval in milliseconds |
105
+ | `writeBuffer` | `number` | `65536` | Buffer size threshold before flushing (64KB) |
106
+ | `keepDays` | `number` | `1` | Days to keep log files (0 = disable rotation) |
107
+ | `json` | `boolean` | `false` | Output logs in JSON format |
108
+ | `toFile` | `string[]` | `['log', 'info', 'warn', 'debug', 'error']` | Log tags to write to file |
109
+ | `toStdout` | `string[]` | `['log', 'info', 'warn', 'debug', 'error']` | Log tags to write to stdout |
110
+ | `createStream` | `function` | `fs.createWriteStream` | Custom stream factory function |
111
+ | `crash` | `string` | `undefined` | Crash handling mode ('flush' to flush on exit) |
112
+
113
+ ### Log Tags
114
+
115
+ Metalog supports five log tags that can be filtered independently for file and console output:
116
+
117
+ - `log` - General logging
118
+ - `info` - Informational messages
119
+ - `warn` - Warning messages
120
+ - `debug` - Debug messages
121
+ - `error` - Error messages
122
+
123
+ ## Advanced Usage
124
+
125
+ ### Custom Stream Factory
126
+
127
+ ```js
128
+ const logger = await Logger.create({
129
+ path: './log',
130
+ home: process.cwd(),
131
+ createStream: (filePath) => {
132
+ // Custom compression stream
133
+ const fs = require('fs');
134
+ const zlib = require('zlib');
135
+ const gzip = zlib.createGzip();
136
+ const writeStream = fs.createWriteStream(filePath + '.gz');
137
+ return gzip.pipe(writeStream);
138
+ },
139
+ });
140
+ ```
141
+
142
+ ### Selective Logging
143
+
144
+ ```js
145
+ // Only log errors to file, all tags to console
146
+ const logger = await Logger.create({
147
+ path: './log',
148
+ home: process.cwd(),
149
+ toFile: ['error'],
150
+ toStdout: ['log', 'info', 'warn', 'debug', 'error'],
151
+ });
152
+
153
+ // Only log info and above tags to both file and console
154
+ const logger = await Logger.create({
155
+ path: './log',
156
+ home: process.cwd(),
157
+ toFile: ['info', 'warn', 'error'],
158
+ toStdout: ['info', 'warn', 'error'],
159
+ });
160
+ ```
161
+
162
+ ### JSON Logging
163
+
164
+ ```js
165
+ const logger = await Logger.create({
166
+ path: './log',
167
+ home: process.cwd(),
168
+ json: true,
169
+ });
170
+
171
+ logger.console.info('User action', { userId: 123, action: 'login' });
172
+ // Output: {"timestamp":"2025-01-07T10:30:00.000Z","worker":"W0","tag":"info","message":"User action","userId":123,"action":"login"}
173
+ ```
174
+
175
+ ### Log Rotation and Cleanup
176
+
177
+ ```js
178
+ const logger = await Logger.create({
179
+ path: './log',
180
+ home: process.cwd(),
181
+ keepDays: 7, // Keep logs for 7 days
182
+ workerId: 1,
183
+ });
184
+
185
+ // Manual rotation
186
+ await logger.rotate();
187
+
188
+ // Log files are automatically rotated daily
189
+ // Old files are cleaned up based on keepDays setting
190
+ ```
191
+
192
+ ### Error Handling
193
+
194
+ ```js
195
+ const logger = await Logger.create({
196
+ path: './log',
197
+ home: process.cwd(),
198
+ crash: 'flush', // Flush buffer on process exit
199
+ });
200
+
201
+ logger.on('error', (error) => {
202
+ console.error('Logger error:', error.message);
203
+ });
204
+
205
+ // Graceful shutdown
206
+ process.on('SIGTERM', async () => {
207
+ await logger.close();
208
+ process.exit(0);
209
+ });
210
+ ```
211
+
212
+ ## API Reference
213
+
214
+ ### Logger Class
215
+
216
+ #### Constructor
217
+
218
+ ```js
219
+ new Logger(options: LoggerOptions): Promise<Logger>
220
+ ```
221
+
222
+ #### Static Methods
223
+
224
+ - `Logger.create(options: LoggerOptions): Promise<Logger>` - Create and open logger
225
+
226
+ #### Instance Methods
227
+
228
+ - `open(): Promise<Logger>` - Open log file and start logging
229
+ - `close(): Promise<void>` - Close logger and flush remaining data
230
+ - `rotate(): Promise<void>` - Manually trigger log rotation
231
+ - `write(tag: string, indent: number, args: unknown[]): void` - Low-level write method
232
+ - `flush(callback?: (error?: Error) => void): void` - Flush buffer to disk
233
+
234
+ #### Properties
235
+
236
+ - `active: boolean` - Whether logger is currently active
237
+ - `path: string` - Log directory path
238
+ - `home: string` - Home directory for path normalization
239
+ - `console: Console` - Console instance for logging
240
+
241
+ ### BufferedStream Class
242
+
243
+ ```js
244
+ const stream = new BufferedStream({
245
+ stream: fs.createWriteStream('output.log'),
246
+ writeBuffer: 32 * 1024, // 32KB buffer
247
+ flushInterval: 5000, // 5 second flush interval
248
+ });
249
+
250
+ stream.write(Buffer.from('data'));
251
+ stream.flush();
252
+ await stream.close();
253
+ ```
254
+
255
+ ### Formatter Class
256
+
257
+ ```js
258
+ const formatter = new Formatter({
259
+ worker: 'W1',
260
+ home: '/app',
261
+ json: false,
262
+ });
263
+
264
+ const formatted = formatter.formatPretty('info', 0, ['Message']);
265
+ const jsonOutput = formatter.formatJson('error', 0, [error]);
266
+ ```
267
+
268
+ ## Best Practices
269
+
270
+ ### Performance Optimization
271
+
272
+ 1. **Buffer Size**: Adjust `writeBuffer` based on your log volume
273
+ - High volume: 128KB or larger
274
+ - Low volume: 16KB or smaller
275
+
276
+ 2. **Flush Interval**: Balance between performance and data safety
277
+ - Production: 3-10 seconds
278
+ - Development: 1-3 seconds
279
+
280
+ 3. **Selective Logging**: Use `toFile` and `toStdout` to reduce I/O
281
+ ```js
282
+ // Production: Only errors to file, warnings+ to console
283
+ toFile: ['error'],
284
+ toStdout: ['warn', 'error']
285
+ ```
286
+
287
+ ### Log Management
288
+
289
+ 1. **Rotation Strategy**: Set appropriate `keepDays` based on storage
290
+ 2. **Path Organization**: Use structured paths for multi-service deployments
291
+
292
+ ```js
293
+ path: `/var/log/app/${process.env.NODE_ENV}/${process.env.SERVICE_NAME}`;
294
+ ```
295
+
296
+ 3. **Error Handling**: Always handle logger errors
297
+ ```js
298
+ logger.on('error', (error) => {
299
+ // Fallback logging or alerting
300
+ });
301
+ ```
302
+
303
+ ### Development vs Production
304
+
305
+ ```js
306
+ const isDevelopment = process.env.NODE_ENV === 'development';
307
+
308
+ const logger = await Logger.create({
309
+ path: './log',
310
+ home: process.cwd(),
311
+ json: !isDevelopment, // JSON in production, pretty in dev
312
+ toFile: isDevelopment ? ['log', 'info', 'warn', 'error'] : ['error'],
313
+ toStdout: isDevelopment
314
+ ? ['log', 'info', 'warn', 'debug', 'error']
315
+ : ['warn', 'error'],
316
+ flushInterval: isDevelopment ? 1000 : 5000,
317
+ keepDays: isDevelopment ? 1 : 30,
318
+ });
319
+ ```
320
+
321
+ ## Troubleshooting
322
+
323
+ ### Common Issues
324
+
325
+ **Logs not appearing in files:**
326
+
327
+ - Check `path` directory exists and is writable
328
+ - Verify `toFile` array includes desired log tags
329
+ - Ensure logger is properly opened with `await logger.open()`
330
+
331
+ **High memory usage:**
332
+
333
+ - Reduce `writeBuffer` size
334
+ - Increase `flushInterval` frequency
335
+ - Use selective logging with `toFile`/`toStdout`
336
+
337
+ **Missing logs on crash:**
338
+
339
+ - Set `crash: 'flush'` option
340
+ - Handle process signals properly
341
+ - Use try/catch around critical operations
342
+
343
+ **Performance issues:**
344
+
345
+ - Use JSON format for high-volume logging
346
+ - Disable file logging for debug tags in production
347
+ - Consider using separate loggers for different components
348
+
349
+ ### Debug Mode
350
+
351
+ ```js
352
+ // Enable debug logging
353
+ const logger = await Logger.create({
354
+ path: './log',
355
+ home: process.cwd(),
356
+ toStdout: ['debug', 'info', 'warn', 'error'],
357
+ });
358
+
359
+ logger.console.debug('Debug information', { data: 'value' });
360
+ ```
361
+
32
362
  ## License & Contributors
33
363
 
34
364
  Copyright (c) 2017-2025 [Metarhia contributors](https://github.com/metarhia/metalog/graphs/contributors).
package/metalog.d.ts CHANGED
@@ -5,70 +5,97 @@ interface LoggerOptions {
5
5
  home: string;
6
6
  workerId?: number;
7
7
  createStream?: () => NodeJS.WritableStream;
8
- writeInterval: number;
9
- writeBuffer: number;
10
- keepDays: number;
8
+ writeBuffer?: number;
9
+ flushInterval?: number;
10
+ keepDays?: number;
11
11
  json?: boolean;
12
12
  toFile?: Array<string>;
13
13
  toStdout?: Array<string>;
14
14
  crash?: string;
15
15
  }
16
16
 
17
- interface Console {
18
- assert(assertion: unknown, ...args: unknown[]): void;
17
+ interface BufferedStreamOptions {
18
+ stream?: NodeJS.WritableStream;
19
+ writeBuffer?: number;
20
+ flushInterval?: number;
21
+ }
22
+
23
+ interface FormatterOptions {
24
+ json?: boolean;
25
+ worker?: string;
26
+ home?: string;
27
+ }
28
+
29
+ export class BufferedStream extends EventEmitter {
30
+ constructor(options?: BufferedStreamOptions);
31
+ write(buffer: Buffer): void;
32
+ flush(callback?: (error?: Error) => void): void;
33
+ close(): Promise<void>;
34
+ }
35
+
36
+ export class Formatter {
37
+ constructor(options?: FormatterOptions);
38
+ format(tag: string, indent: number, args: unknown[]): string;
39
+ formatPretty(tag: string, indent: number, args: unknown[]): string;
40
+ formatFile(tag: string, indent: number, args: unknown[]): string;
41
+ formatJson(tag: string, indent: number, args: unknown[]): string;
42
+ normalizeStack(stack: string): string;
43
+ expandError(error: Error): unknown;
44
+ }
45
+
46
+ export class Console {
47
+ constructor(logger: Logger);
48
+ assert(value: unknown, ...message: unknown[]): void;
19
49
  clear(): void;
20
50
  count(label?: string): void;
21
51
  countReset(label?: string): void;
22
- debug(...args: unknown[]): void;
23
- dir(...args: unknown[]): void;
24
- trace(...args: unknown[]): void;
25
- info(...args: unknown[]): void;
26
- log(...args: unknown[]): void;
27
- warn(...args: unknown[]): void;
28
- error(...args: unknown[]): void;
29
- group(...args: unknown[]): void;
30
- groupCollapsed(...args: unknown[]): void;
52
+ debug(data: unknown, ...args: unknown[]): void;
53
+ dir(obj: unknown, options?: unknown): void;
54
+ dirxml(...data: unknown[]): void;
55
+ error(data?: unknown, ...args: unknown[]): void;
56
+ group(...label: unknown[]): void;
57
+ groupCollapsed(...label: unknown[]): void;
31
58
  groupEnd(): void;
32
- table(tabularData: unknown): void;
59
+ info(data?: unknown, ...args: unknown[]): void;
60
+ log(data?: unknown, ...args: unknown[]): void;
61
+ table(tabularData: unknown, properties?: string[]): void;
33
62
  time(label?: string): void;
34
63
  timeEnd(label?: string): void;
35
- timeLog(label: string, ...args: unknown[]): void;
64
+ timeLog(label?: string, ...data: unknown[]): void;
65
+ trace(message?: unknown, ...args: unknown[]): void;
66
+ warn(data?: unknown, ...args: unknown[]): void;
36
67
  }
37
68
 
38
69
  export class Logger extends EventEmitter {
39
70
  active: boolean;
40
71
  path: string;
41
- workerId: string;
42
- createStream: () => NodeJS.WritableStream;
43
- writeInterval: number;
44
- writeBuffer: number;
45
- keepDays: number;
46
72
  home: string;
47
- json: boolean;
48
- stream: NodeJS.WritableStream;
49
- reopenTimer: NodeJS.Timer;
50
- flushTimer: NodeJS.Timer;
51
- lock: boolean;
52
- buffer: Array<Buffer>;
53
- bufferLength: number;
54
- file: string;
55
- toFile: Record<string, boolean>;
56
- fsEnabled: boolean;
57
- toStdout: Record<string, boolean>;
58
73
  console: Console;
59
- constructor(args: LoggerOptions);
60
- createLogDir(): Promise<void>;
74
+
75
+ constructor(options: LoggerOptions);
76
+ static create(options: LoggerOptions): Promise<Logger>;
61
77
  open(): Promise<Logger>;
62
78
  close(): Promise<void>;
63
79
  rotate(): Promise<void>;
64
- format(type: string, indent: number, ...args: unknown[]): string;
65
- formatPretty(type: string, indent: number, ...args: unknown[]): string;
66
- formatFile(type: string, indent: number, ...args: unknown[]): string;
67
- formatJson(type: string, indent: number, ...args: unknown[]): string;
68
- write(type: string, indent: number, ...args: unknown[]): void;
69
- flush(callback?: (err?: Error) => void): void;
70
- normalizeStack(stack: string): string;
71
- expandError(err: Error): unknown;
80
+ write(tag: string, indent: number, args: unknown[]): void;
81
+ flush(callback?: (error?: Error) => void): void;
82
+
83
+ #options: LoggerOptions;
84
+ #worker: string;
85
+ #createStream: () => NodeJS.WritableStream;
86
+ #keepDays: number;
87
+ #stream: NodeJS.WritableStream | null;
88
+ #rotationTimer: NodeJS.Timer | null;
89
+ #file: string;
90
+ #fsEnabled: boolean;
91
+ #toFile: Record<string, boolean> | null;
92
+ #toStdout: Record<string, boolean> | null;
93
+ #buffer: BufferedStream | null;
94
+ #formatter: Formatter;
95
+
96
+ #createDir(): Promise<void>;
97
+ #setupCrashHandling(): void;
72
98
  }
73
99
 
74
- export function openLog(args: LoggerOptions): Promise<Logger>;
100
+ export function nowDays(): number;
101
+ export function nameToDays(fileName: string): number;
package/metalog.js CHANGED
@@ -4,26 +4,26 @@ const fs = require('node:fs');
4
4
  const fsp = fs.promises;
5
5
  const path = require('node:path');
6
6
  const util = require('node:util');
7
- const events = require('node:events');
7
+ const EventEmitter = require('node:events');
8
8
  const readline = require('node:readline');
9
9
  const metautil = require('metautil');
10
10
  const concolor = require('concolor');
11
11
 
12
12
  const DAY_MILLISECONDS = metautil.duration('1d');
13
- const DEFAULT_WRITE_INTERVAL = metautil.duration('3s');
14
- const DEFAULT_BUFFER_SIZE = 64 * 1024;
13
+ const DEFAULT_FLUSH_INTERVAL = metautil.duration('3s');
14
+ const DEFAULT_BUFFER_THRESHOLD = 64 * 1024;
15
15
  const DEFAULT_KEEP_DAYS = 1;
16
16
  const STACK_AT = ' at ';
17
- const TYPE_LENGTH = 6;
17
+ const TAG_LENGTH = 6;
18
18
  const LINE_SEPARATOR = ';';
19
19
  const INDENT = 2;
20
20
  const DATE_LEN = 'YYYY-MM-DD'.length;
21
21
  const TIME_START = DATE_LEN + 1;
22
22
  const TIME_END = TIME_START + 'HH:MM:SS'.length;
23
23
 
24
- const LOG_TYPES = ['log', 'info', 'warn', 'debug', 'error'];
24
+ const LOG_TAGS = ['log', 'info', 'warn', 'debug', 'error'];
25
25
 
26
- const TYPE_COLOR = concolor({
26
+ const TAG_COLOR = concolor({
27
27
  log: 'b,black/white',
28
28
  info: 'b,white/blue',
29
29
  warn: 'b,black/yellow',
@@ -47,11 +47,9 @@ const DEFAULT_FLAGS = {
47
47
  error: false,
48
48
  };
49
49
 
50
- const logTypes = (types) => {
50
+ const logTags = (tags) => {
51
51
  const flags = { ...DEFAULT_FLAGS };
52
- for (const type of types) {
53
- flags[type] = true;
54
- }
52
+ for (const tag of tags) flags[tag] = true;
55
53
  return flags;
56
54
  };
57
55
 
@@ -64,9 +62,17 @@ const nowDays = () => {
64
62
  return Math.floor(date.getTime() / DAY_MILLISECONDS);
65
63
  };
66
64
 
67
- const nameToDays = (fileName) => {
65
+ const nameToDays = (fileName = '') => {
66
+ if (fileName.length < DATE_LEN) {
67
+ throw new Error(`Invalid filename: ${fileName}`);
68
+ }
68
69
  const date = fileName.substring(0, DATE_LEN);
69
- const fileTime = new Date(date).getTime();
70
+ const [year, month, day] = date.split('-').map(Number);
71
+ const fileDate = new Date(year, month - 1, day, 0, 0, 0, 0);
72
+ const fileTime = fileDate.getTime();
73
+ if (isNaN(fileTime)) {
74
+ throw new Error(`Invalid filename: ${fileName}`);
75
+ }
70
76
  return Math.floor(fileTime / DAY_MILLISECONDS);
71
77
  };
72
78
 
@@ -77,22 +83,148 @@ const getNextReopen = () => {
77
83
  return nextDate - curTime + DAY_MILLISECONDS;
78
84
  };
79
85
 
86
+ class BufferedStream extends EventEmitter {
87
+ #writable = null;
88
+ #buffers = [];
89
+ #size = 0;
90
+ #threshold = DEFAULT_BUFFER_THRESHOLD;
91
+ #flushing = false;
92
+ #flushTimer = null;
93
+
94
+ constructor(options = {}) {
95
+ super();
96
+ const { stream, writeBuffer, flushInterval } = options;
97
+ if (!stream) throw new Error('Stream is required');
98
+ this.#writable = stream;
99
+ if (writeBuffer) this.#threshold = writeBuffer;
100
+ const interval = flushInterval || DEFAULT_FLUSH_INTERVAL;
101
+ this.#flushTimer = setInterval(() => void this.flush(), interval);
102
+ }
103
+
104
+ write(buffer) {
105
+ this.#buffers.push(buffer);
106
+ this.#size += buffer.length;
107
+ if (this.#size >= this.#threshold) this.flush();
108
+ }
109
+
110
+ flush(callback) {
111
+ if (this.#flushing) {
112
+ if (callback) this.once('drain', callback);
113
+ return;
114
+ }
115
+ if (this.#size === 0) {
116
+ if (callback) callback();
117
+ return;
118
+ }
119
+ if (this.#writable.destroyed || this.#writable.closed) {
120
+ if (callback) callback();
121
+ return;
122
+ }
123
+ this.#flushing = true;
124
+ const buffer = Buffer.concat(this.#buffers);
125
+ this.#buffers.length = 0;
126
+ this.#size = 0;
127
+ this.#writable.write(buffer, (error) => {
128
+ this.#flushing = false;
129
+ this.emit('drain');
130
+ if (callback) callback(error);
131
+ });
132
+ }
133
+
134
+ async close() {
135
+ clearInterval(this.#flushTimer);
136
+ return new Promise((resolve, reject) => {
137
+ this.flush((error) => {
138
+ if (error) return void reject(error);
139
+ this.#writable.end(resolve);
140
+ });
141
+ });
142
+ }
143
+ }
144
+
145
+ class Formatter {
146
+ #worker = 'W0';
147
+ #home = './';
148
+
149
+ constructor(options = {}) {
150
+ const { worker, home } = options;
151
+ if (worker) this.#worker = worker;
152
+ if (home) this.#home = home;
153
+ }
154
+
155
+ format(tag, indent, args) {
156
+ let line = util.format(...args);
157
+ if (tag === 'error' || tag === 'debug') line = this.normalizeStack(line);
158
+ return ' '.repeat(indent) + line;
159
+ }
160
+
161
+ formatPretty(tag, indent, args) {
162
+ const dateTime = new Date().toISOString();
163
+ const message = this.format(tag, indent, args);
164
+ const normalColor = TEXT_COLOR[tag];
165
+ const markColor = TAG_COLOR[tag];
166
+ const time = normalColor(dateTime.substring(TIME_START, TIME_END));
167
+ const id = normalColor(this.#worker);
168
+ const mark = markColor(' ' + tag.padEnd(TAG_LENGTH));
169
+ const msg = normalColor(message);
170
+ return `${time} ${id} ${mark} ${msg}`;
171
+ }
172
+
173
+ formatFile(tag, indent, args) {
174
+ const dateTime = new Date().toISOString();
175
+ const message = this.format(tag, indent, args);
176
+ const msg = metautil.replace(message, '\n', LINE_SEPARATOR);
177
+ return `${dateTime} [${tag}] ${msg}`;
178
+ }
179
+
180
+ formatJson(tag, indent, args) {
181
+ const timestamp = new Date().toISOString();
182
+ const json = { timestamp, worker: this.#worker, tag, message: null };
183
+ let data = args.slice();
184
+ const head = data[0];
185
+ if (metautil.isError(head)) {
186
+ json.error = this.expandError(head);
187
+ data = data.slice(1);
188
+ } else if (typeof head === 'object') {
189
+ Object.assign(json, head);
190
+ data = data.slice(1);
191
+ }
192
+ json.message = util.format(...data);
193
+ return JSON.stringify(json);
194
+ }
195
+
196
+ normalizeStack(stack) {
197
+ if (!stack) return 'No stack trace to log';
198
+ let res = metautil.replace(stack, STACK_AT, '');
199
+ if (this.#home) res = metautil.replace(res, this.#home, '');
200
+ return res;
201
+ }
202
+
203
+ expandError(error) {
204
+ return {
205
+ message: error.message,
206
+ stack: this.normalizeStack(error.stack),
207
+ ...error,
208
+ };
209
+ }
210
+ }
211
+
80
212
  class Console {
81
- #write;
213
+ #logger;
82
214
  #groupIndent = 0;
83
215
  #counts = new Map();
84
216
  #times = new Map();
85
217
  #readline = readline;
86
218
 
87
- constructor(write) {
88
- this.#write = write;
219
+ constructor(logger) {
220
+ this.#logger = logger;
89
221
  }
90
222
 
91
223
  assert(assertion, ...args) {
92
224
  if (!assertion) {
93
225
  const noArgs = args.length === 0;
94
226
  const message = noArgs ? 'Assertion failed' : util.format(...args);
95
- this.#write('error', this.#groupIndent, message);
227
+ this.#logger.write('error', this.#groupIndent, [message]);
96
228
  }
97
229
  }
98
230
 
@@ -105,7 +237,7 @@ class Console {
105
237
  let cnt = this.#counts.get(label) || 0;
106
238
  cnt++;
107
239
  this.#counts.set(label, cnt);
108
- this.#write('debug', this.#groupIndent, `${label}: ${cnt}`);
240
+ this.#logger.write('debug', this.#groupIndent, [`${label}: ${cnt}`]);
109
241
  }
110
242
 
111
243
  countReset(label = 'default') {
@@ -113,33 +245,38 @@ class Console {
113
245
  }
114
246
 
115
247
  debug(...args) {
116
- this.#write('debug', this.#groupIndent, ...args);
248
+ this.#logger.write('debug', this.#groupIndent, args);
249
+ }
250
+
251
+ dir(obj, options) {
252
+ const inspected = util.inspect(obj, options);
253
+ this.#logger.write('debug', this.#groupIndent, [inspected]);
117
254
  }
118
255
 
119
- dir(...args) {
120
- this.#write('debug', this.#groupIndent, ...args);
256
+ dirxml(...data) {
257
+ this.#logger.write('debug', this.#groupIndent, data);
121
258
  }
122
259
 
123
260
  trace(...args) {
124
261
  const msg = util.format(...args);
125
262
  const err = new Error(msg);
126
- this.#write('debug', this.#groupIndent, `Trace${err.stack}`);
263
+ this.#logger.write('debug', this.#groupIndent, [`Trace${err.stack}`]);
127
264
  }
128
265
 
129
266
  info(...args) {
130
- this.#write('info', this.#groupIndent, ...args);
267
+ this.#logger.write('info', this.#groupIndent, args);
131
268
  }
132
269
 
133
270
  log(...args) {
134
- this.#write('log', this.#groupIndent, ...args);
271
+ this.#logger.write('log', this.#groupIndent, args);
135
272
  }
136
273
 
137
274
  warn(...args) {
138
- this.#write('warn', this.#groupIndent, ...args);
275
+ this.#logger.write('warn', this.#groupIndent, args);
139
276
  }
140
277
 
141
278
  error(...args) {
142
- this.#write('error', this.#groupIndent, ...args);
279
+ this.#logger.write('error', this.#groupIndent, args);
143
280
  }
144
281
 
145
282
  group(...args) {
@@ -156,8 +293,22 @@ class Console {
156
293
  this.#groupIndent -= INDENT;
157
294
  }
158
295
 
159
- table(tabularData) {
160
- this.#write('log', 0, JSON.stringify(tabularData));
296
+ table(tabularData, properties) {
297
+ const opts = { showHidden: false, depth: null, colors: false };
298
+ let data = tabularData;
299
+ if (properties) {
300
+ if (!Array.isArray(data)) data = [data];
301
+ data = data.map((item) => {
302
+ const record = {};
303
+ for (const prop of properties) {
304
+ if (Object.prototype.hasOwnProperty.call(item, prop)) {
305
+ record[prop] = item[prop];
306
+ }
307
+ }
308
+ return record;
309
+ });
310
+ }
311
+ this.#logger.write('log', 0, [util.inspect(data, opts)]);
161
312
  }
162
313
 
163
314
  time(label = 'default') {
@@ -172,131 +323,124 @@ class Console {
172
323
  this.#times.delete(label);
173
324
  }
174
325
 
175
- timeLog(label, ...args) {
326
+ timeLog(label = 'default', ...data) {
176
327
  const startTime = this.#times.get(label);
177
328
  if (startTime === undefined) {
178
329
  const msg = `Warning: No such label '${label}'`;
179
- this.#write('warn', this.#groupIndent, msg);
330
+ this.#logger.write('warn', this.#groupIndent, [msg]);
180
331
  return;
181
332
  }
182
- this.#write('debug', this.#groupIndent, ...args);
333
+ const totalTime = process.hrtime(startTime);
334
+ const totalTimeMs = totalTime[0] * 1e3 + totalTime[1] / 1e6;
335
+ const message = data.length > 0 ? util.format(...data) : '';
336
+ const output = `${label}: ${totalTimeMs}ms${message ? ' ' + message : ''}`;
337
+ this.#logger.write('debug', this.#groupIndent, [output]);
183
338
  }
184
339
  }
185
340
 
186
- class Logger extends events.EventEmitter {
341
+ class Logger extends EventEmitter {
342
+ active = false;
343
+ #worker = 'W0';
344
+ #createStream = fs.createWriteStream;
345
+ #options = null;
346
+ #keepDays = DEFAULT_KEEP_DAYS;
347
+ #stream = null;
348
+ #rotationTimer = null;
349
+ #file = '';
350
+ #fsEnabled = false;
351
+ #toFile = null;
352
+ #toStdout = null;
353
+ #buffer = null;
354
+ #formatter = null;
355
+
187
356
  constructor(options) {
188
357
  super();
189
- const { workerId = 0, createStream = fs.createWriteStream } = options;
190
- const { writeInterval, writeBuffer, keepDays, home, json } = options;
191
- const { toFile = LOG_TYPES, toStdout = LOG_TYPES, crash } = options;
192
- this.active = false;
358
+ this.#options = options;
359
+ const { workerId, createStream, keepDays, home, crash, json } = options;
360
+ const { toFile = LOG_TAGS, toStdout = LOG_TAGS } = options;
193
361
  this.path = options.path;
194
- this.workerId = `W${workerId}`;
195
- this.createStream = createStream;
196
- this.writeInterval = writeInterval || DEFAULT_WRITE_INTERVAL;
197
- this.writeBuffer = writeBuffer || DEFAULT_BUFFER_SIZE;
198
- this.keepDays = keepDays || DEFAULT_KEEP_DAYS;
199
362
  this.home = home;
200
- this.json = Boolean(json);
201
- this.stream = null;
202
- this.reopenTimer = null;
203
- this.flushTimer = null;
204
- this.lock = false;
205
- this.buffer = [];
206
- this.bufferLength = 0;
207
- this.file = '';
208
- this.toFile = logTypes(toFile);
209
- this.fsEnabled = toFile.length !== 0;
210
- this.toStdout = logTypes(toStdout);
211
- this.console = new Console((...args) => this.write(...args));
363
+ this.console = new Console(this);
364
+ if (workerId) this.#worker = `W${workerId}`;
365
+ if (toFile) this.#toFile = logTags(toFile);
366
+ if (toStdout) this.#toStdout = logTags(toStdout);
367
+ if (createStream) this.#createStream = createStream;
368
+ if (keepDays) this.#keepDays = keepDays;
212
369
  if (crash === 'flush') this.#setupCrashHandling();
370
+ this.#fsEnabled = toFile.length !== 0;
371
+ this.#buffer = null;
372
+ this.#formatter = new Formatter({ json, worker: this.#worker, home });
213
373
  return this.open();
214
374
  }
215
375
 
216
- createLogDir() {
217
- return new Promise((resolve, reject) => {
218
- fs.access(this.path, (err) => {
219
- if (!err) resolve();
220
- fs.mkdir(this.path, (err) => {
221
- if (!err || err.code === 'EEXIST') return void resolve();
222
- const error = new Error(`Can not create directory: ${this.path}\n`);
223
- this.emit('error', error);
224
- reject();
225
- });
226
- });
227
- });
376
+ static async create(options) {
377
+ return new Logger(options);
228
378
  }
229
379
 
230
380
  async open() {
231
381
  if (this.active) return this;
232
382
  this.active = true;
233
- if (!this.fsEnabled) {
234
- process.nextTick(() => this.emit('open'));
235
- return this;
236
- }
237
- await this.createLogDir();
238
- const fileName = metautil.nowDate() + '-' + this.workerId + '.log';
239
- this.file = path.join(this.path, fileName);
383
+ if (!this.#fsEnabled) return this;
384
+ await this.#createDir();
385
+ const fileName = metautil.nowDate() + '-' + this.#worker + '.log';
386
+ this.#file = path.join(this.path, fileName);
240
387
  const nextReopen = getNextReopen();
241
- this.reopenTimer = setTimeout(() => {
388
+ this.#rotationTimer = setTimeout(() => {
242
389
  this.once('close', () => {
243
390
  this.open();
244
391
  });
245
- this.close().catch((err) => {
246
- process.stdout.write(`${err.stack}\n`);
247
- this.emit('error', err);
392
+ this.close().catch((error) => {
393
+ this.emit('error', error);
248
394
  });
249
395
  }, nextReopen);
250
- if (this.keepDays) await this.rotate();
251
- this.stream = this.createStream(this.file, { flags: 'a' });
252
- this.flushTimer = setInterval(() => {
253
- this.flush();
254
- }, this.writeInterval);
255
- this.stream.on('open', () => {
256
- this.emit('open');
396
+ if (this.#keepDays) await this.rotate();
397
+ const stream = this.#createStream(this.#file, { flags: 'a' });
398
+ this.#stream = stream;
399
+ const { writeBuffer, flushInterval } = this.#options;
400
+ this.#buffer = new BufferedStream({ writeBuffer, stream, flushInterval });
401
+ stream.on('error', (error) => {
402
+ const errorMsg = `Can't open log file: ${this.#file}, ${error.message}`;
403
+ this.emit('error', new Error(errorMsg));
257
404
  });
258
- this.stream.on('error', () => {
259
- this.emit('error', new Error(`Can't open log file: ${this.file}`));
260
- });
261
- await events.once(this, 'open');
405
+ await EventEmitter.once(stream, 'open');
262
406
  return this;
263
407
  }
264
408
 
265
409
  async close() {
266
410
  if (!this.active) return Promise.resolve();
267
- if (!this.fsEnabled) {
411
+ if (!this.#fsEnabled) {
268
412
  this.active = false;
269
413
  this.emit('close');
270
414
  return Promise.resolve();
271
415
  }
272
- const { stream } = this;
273
- if (!stream || stream.destroyed || stream.closed) {
274
- return Promise.resolve();
275
- }
416
+ const stream = this.#stream;
417
+ if (stream.destroyed || stream.closed) return Promise.resolve();
418
+ clearTimeout(this.#rotationTimer);
419
+ this.#rotationTimer = null;
276
420
  return new Promise((resolve, reject) => {
277
- this.flush((err) => {
278
- if (err) return void reject(err);
421
+ this.flush((error) => {
422
+ if (error) return void reject(error);
279
423
  this.active = false;
280
- stream.end(() => {
281
- clearInterval(this.flushTimer);
282
- clearTimeout(this.reopenTimer);
283
- this.flushTimer = null;
284
- this.reopenTimer = null;
285
- const fileName = this.file;
286
- this.emit('close');
287
- fs.stat(fileName, (err, stats) => {
288
- if (!err && stats.size === 0) {
424
+ this.#buffer
425
+ .close()
426
+ .then(() => {
427
+ const fileName = this.#file;
428
+ this.emit('close');
429
+ fs.stat(fileName, (error, stats) => {
430
+ if (error || stats.size > 0) {
431
+ return void resolve();
432
+ }
289
433
  fsp.unlink(fileName).catch(() => {});
290
- }
291
- resolve();
292
- });
293
- });
434
+ resolve();
435
+ });
436
+ })
437
+ .catch(reject);
294
438
  });
295
439
  });
296
440
  }
297
441
 
298
442
  async rotate() {
299
- if (!this.keepDays) return;
443
+ if (!this.#keepDays) return;
300
444
  const now = nowDays();
301
445
  const finish = [];
302
446
  try {
@@ -304,148 +448,86 @@ class Logger extends events.EventEmitter {
304
448
  for (const fileName of files) {
305
449
  if (metautil.fileExt(fileName) !== 'log') continue;
306
450
  const fileAge = now - nameToDays(fileName);
307
- if (fileAge < this.keepDays) continue;
308
- finish.push(fsp.unlink(path.join(this.path, fileName)));
451
+ if (fileAge < this.#keepDays) continue;
452
+ const promise = fsp
453
+ .unlink(path.join(this.path, fileName))
454
+ .catch(() => {});
455
+ finish.push(promise);
309
456
  }
310
457
  await Promise.all(finish);
311
- } catch (err) {
312
- process.stdout.write(`${err.stack}\n`);
313
- this.emit('error', err);
458
+ } catch (error) {
459
+ process.stdout.write(`${error.stack}\n`);
460
+ this.emit('error', error);
314
461
  }
315
462
  }
316
463
 
317
- format(type, indent, ...args) {
318
- const normalize = type === 'error' || type === 'debug';
319
- const s = `${' '.repeat(indent)}${util.format(...args)}`;
320
- return normalize ? this.normalizeStack(s) : s;
321
- }
322
-
323
- formatPretty(type, indent, ...args) {
324
- const dateTime = new Date().toISOString();
325
- const message = this.format(type, indent, ...args);
326
- const normalColor = TEXT_COLOR[type];
327
- const markColor = TYPE_COLOR[type];
328
- const time = normalColor(dateTime.substring(TIME_START, TIME_END));
329
- const id = normalColor(this.workerId);
330
- const mark = markColor(' ' + type.padEnd(TYPE_LENGTH));
331
- const msg = normalColor(message);
332
- return `${time} ${id} ${mark} ${msg}`;
333
- }
334
-
335
- formatFile(type, indent, ...args) {
336
- const dateTime = new Date().toISOString();
337
- const message = this.format(type, indent, ...args);
338
- const msg = metautil.replace(message, '\n', LINE_SEPARATOR);
339
- return `${dateTime} [${type}] ${msg}`;
340
- }
341
-
342
- formatJson(type, indent, ...args) {
343
- const log = {
344
- timestamp: new Date().toISOString(),
345
- workerId: this.workerId,
346
- level: type,
347
- message: null,
348
- };
349
- if (metautil.isError(args[0])) {
350
- log.err = this.expandError(args[0]);
351
- args = args.slice(1);
352
- } else if (typeof args[0] === 'object') {
353
- Object.assign(log, args[0]);
354
- if (metautil.isError(log.err)) log.err = this.expandError(log.err);
355
- if (metautil.isError(log.error)) log.error = this.expandError(log.error);
356
- args = args.slice(1);
357
- }
358
- log.message = util.format(...args);
359
- return JSON.stringify(log);
464
+ #createDir() {
465
+ return new Promise((resolve, reject) => {
466
+ fs.access(this.path, (error) => {
467
+ if (!error) resolve();
468
+ fs.mkdir(this.path, (error) => {
469
+ if (!error || error.code === 'EEXIST') {
470
+ return void resolve();
471
+ } else {
472
+ const error = new Error(`Can not create directory: ${this.path}`);
473
+ this.emit('error', error);
474
+ reject(error);
475
+ }
476
+ });
477
+ });
478
+ });
360
479
  }
361
480
 
362
- write(type, indent, ...args) {
363
- if (this.toStdout[type]) {
364
- const line = this.json
365
- ? this.formatJson(type, indent, ...args)
366
- : this.formatPretty(type, indent, ...args);
481
+ write(tag, indent, args) {
482
+ if (this.#toStdout[tag]) {
483
+ const line = this.#options.json
484
+ ? this.#formatter.formatJson(tag, indent, args)
485
+ : this.#formatter.formatPretty(tag, indent, args);
367
486
  process.stdout.write(line + '\n');
368
487
  }
369
- if (this.toFile[type]) {
370
- const line = this.json
371
- ? this.formatJson(type, indent, ...args)
372
- : this.formatFile(type, indent, ...args);
488
+ if (this.#toFile[tag]) {
489
+ const line = this.#options.json
490
+ ? this.#formatter.formatJson(tag, indent, args)
491
+ : this.#formatter.formatFile(tag, indent, args);
373
492
  const buffer = Buffer.from(line + '\n');
374
- this.buffer.push(buffer);
375
- this.bufferLength += buffer.length;
376
- if (this.bufferLength >= this.writeBuffer) this.flush();
493
+ this.#buffer.write(buffer);
377
494
  }
378
495
  }
379
496
 
380
497
  flush(callback) {
381
- if (this.lock) {
382
- if (callback) this.once('unlocked', callback);
383
- return;
384
- }
385
- if (this.buffer.length === 0) {
386
- if (callback) callback();
387
- return;
388
- }
389
498
  if (!this.active) {
390
- const err = new Error('Cannot flush log buffer: logger is not active');
391
- this.emit('error', err);
392
- if (callback) callback(err);
499
+ if (callback) callback();
393
500
  return;
394
501
  }
395
- if (!this.stream || this.stream.destroyed || this.stream.closed) {
396
- const err = new Error('Cannot flush log buffer: stream is not available');
397
- this.emit('error', err);
398
- if (callback) callback(err);
502
+ if (!this.#buffer) {
503
+ if (callback) callback();
399
504
  return;
400
505
  }
401
- this.lock = true;
402
- const buffer = Buffer.concat(this.buffer);
403
- this.buffer.length = 0;
404
- this.bufferLength = 0;
405
- this.stream.write(buffer, () => {
406
- this.lock = false;
407
- this.emit('unlocked');
408
- if (callback) callback();
506
+ this.#buffer.flush((error) => {
507
+ if (error) this.emit('error', error);
508
+ if (callback) callback(error);
409
509
  });
410
510
  }
411
511
 
412
- normalizeStack(stack) {
413
- if (!stack) return 'no data to log';
414
- let res = metautil.replace(stack, STACK_AT, '');
415
- if (this.home) res = metautil.replace(res, this.home, '');
416
- return res;
417
- }
418
-
419
- expandError(err) {
420
- return {
421
- message: err.message,
422
- stack: this.normalizeStack(err.stack),
423
- ...err,
424
- };
425
- }
426
-
427
512
  #setupCrashHandling() {
428
513
  const exitHandler = () => {
429
- this.flush();
514
+ if (this.active) this.flush();
430
515
  };
431
516
  process.on('SIGTERM', exitHandler);
432
517
  process.on('SIGINT', exitHandler);
433
518
  process.on('SIGUSR1', exitHandler);
434
519
  process.on('SIGUSR2', exitHandler);
435
- process.on('uncaughtException', (err) => {
436
- this.write('error', 0, 'Uncaught Exception:', err);
437
- this.flush();
438
- });
439
- process.on('unhandledRejection', (reason) => {
440
- this.write('error', 0, 'Unhandled Rejection:', reason);
441
- this.flush();
442
- });
443
- process.on('exit', () => {
444
- this.flush();
445
- });
520
+ process.on('uncaughtException', exitHandler);
521
+ process.on('unhandledRejection', exitHandler);
522
+ process.on('exit', exitHandler);
446
523
  }
447
524
  }
448
525
 
449
- const openLog = async (options) => new Logger(options);
450
-
451
- module.exports = { Logger, openLog };
526
+ module.exports = {
527
+ Logger,
528
+ Console,
529
+ BufferedStream,
530
+ Formatter,
531
+ nowDays,
532
+ nameToDays,
533
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metalog",
3
- "version": "3.1.17",
3
+ "version": "4.0.0-prerelease",
4
4
  "author": "Timur Shemsedinov <timur.shemsedinov@gmail.com>",
5
5
  "description": "Logger for Metarhia",
6
6
  "license": "MIT",
@@ -56,8 +56,8 @@
56
56
  "metautil": "^5.3.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@types/node": "^24.3.0",
60
- "eslint": "^9.34.0",
59
+ "@types/node": "^24.3.1",
60
+ "eslint": "^9.35.0",
61
61
  "eslint-config-metarhia": "^9.1.3",
62
62
  "prettier": "^3.6.2",
63
63
  "typescript": "^5.9.2"