Skip to content

Commit e149af2

Browse files
committed
lib: implement flexible API and error serialization for logger
1 parent 7f5db55 commit e149af2

File tree

2 files changed

+267
-34
lines changed

2 files changed

+267
-34
lines changed

lib/logger.js

Lines changed: 101 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
const {
44
DateNow,
5+
Error,
56
JSONStringify,
67
ObjectAssign,
78
ObjectKeys,
9+
ObjectPrototypeToString,
810
} = primordials;
911

1012
const {
@@ -241,83 +243,151 @@ class Logger {
241243
});
242244
}
243245

246+
/**
247+
* Check if value is an Error object
248+
* @param {*} value
249+
* @returns {boolean}
250+
* @private
251+
*/
252+
_isError(value) {
253+
return value !== null &&
254+
typeof value === 'object' &&
255+
(ObjectPrototypeToString(value) === '[object Error]' ||
256+
value instanceof Error);
257+
}
258+
244259
/**
245260
* Internal log method
246261
* @param {string} level
247262
* @param {number} levelValue
248-
* @param {object} obj
263+
* @param {string|object} msgOrObj - Message string or object with msg property
264+
* @param {object} [fields] - Optional fields to merge
249265
*/
250-
_log(level, levelValue, obj) {
251-
// Note: Level check is now done at method assignment time (noop)
252-
// So this function is only called for enabled levels
253-
254-
// Validate required msg field
255-
validateObject(obj, 'obj');
256-
if (typeof obj.msg !== 'string') {
257-
throw new ERR_INVALID_ARG_TYPE('obj.msg', 'string', obj.msg);
258-
}
266+
_log(level, levelValue, msgOrObj, fields) {
267+
let msg;
268+
let logFields;
269+
270+
// Support two signatures:
271+
// 1. logger.info('message', { fields })
272+
// 2. logger.info({ msg: 'message', other: 'fields' })
273+
// 3. logger.info(new Error('boom'))
274+
275+
if (this._isError(msgOrObj)) {
276+
// Handle Error as first argument
277+
msg = msgOrObj.message;
278+
logFields = {
279+
err: this._serializeError(msgOrObj),
280+
...fields,
281+
};
282+
} else if (typeof msgOrObj === 'string') {
283+
// Support String message
284+
msg = msgOrObj;
285+
logFields = fields || {};
286+
validateObject(logFields, 'fields');
287+
} else {
288+
// Support object with msg property
289+
validateObject(msgOrObj, 'obj');
290+
if (typeof msgOrObj.msg !== 'string') {
291+
throw new ERR_INVALID_ARG_TYPE('obj.msg', 'string', msgOrObj.msg);
292+
}
293+
const { msg: extractedMsg, ...restFields } = msgOrObj;
294+
msg = extractedMsg;
295+
logFields = restFields;
259296

260-
// Extract msg and remaining fields
261-
const { msg, ...fields } = obj;
297+
// Serialize Error objects in fields
298+
if (logFields.err && this._isError(logFields.err)) {
299+
logFields.err = this._serializeError(logFields.err);
300+
}
301+
}
262302

263303
// Build log record
264304
const record = {
265305
level,
266306
msg,
267307
time: DateNow(),
268308
bindings: this._bindings,
269-
fields,
309+
fields: logFields,
270310
};
271311

272312
this._handler.handle(record);
273313
}
274314

315+
/**
316+
* Serialize Error object for logging
317+
* @param {object} err - Error object to serialize
318+
* @returns {object} Serialized error object
319+
* @private
320+
*/
321+
_serializeError(err) {
322+
const serialized = {
323+
message: err.message,
324+
name: err.name,
325+
stack: err.stack,
326+
};
327+
328+
// Add code if it exists
329+
serialized.code ||= err.code;
330+
331+
// Add any enumerable custom properties
332+
for (const key in err) {
333+
serialized[key] ||= err[key];
334+
}
335+
336+
return serialized;
337+
}
338+
275339
/**
276340
* Log at trace level
277-
* @param {object} obj - Object with required msg property
341+
* @param {string|object} msgOrObj - Message string or object with msg property
342+
* @param {object} [fields] - Optional fields to merge
278343
*/
279-
trace(obj) {
280-
this._log('trace', 10, obj);
344+
trace(msgOrObj, fields) {
345+
this._log('trace', 10, msgOrObj, fields);
281346
}
282347

283348
/**
284349
* Log at debug level
285-
* @param {object} obj - Object with required msg property
350+
* @param {string|object} msgOrObj - Message string or object with msg property
351+
* @param {object} [fields] - Optional fields to merge
286352
*/
287-
debug(obj) {
288-
this._log('debug', 20, obj);
353+
debug(msgOrObj, fields) {
354+
this._log('debug', 20, msgOrObj, fields);
289355
}
290356

291357
/**
292358
* Log at info level
293-
* @param {object} obj - Object with required msg property
359+
* @param {string|object} msgOrObj - Message string or object with msg property
360+
* @param {object} [fields] - Optional fields to merge
294361
*/
295-
info(obj) {
296-
this._log('info', 30, obj);
362+
info(msgOrObj, fields) {
363+
this._log('info', 30, msgOrObj, fields);
297364
}
298365

299366
/**
300367
* Log at warn level
301-
* @param {object} obj - Object with required msg property
368+
* @param {string|object} msgOrObj - Message string or object with msg property
369+
* @param {object} [fields] - Optional fields to merge
302370
*/
303-
warn(obj) {
304-
this._log('warn', 40, obj);
371+
warn(msgOrObj, fields) {
372+
this._log('warn', 40, msgOrObj, fields);
305373
}
306374

307375
/**
308376
* Log at error level
309-
* @param {object} obj - Object with required msg property
377+
* @param {string|object} msgOrObj - Message string or object with msg property
378+
* @param {object} [fields] - Optional fields to merge
310379
*/
311-
error(obj) {
312-
this._log('error', 50, obj);
380+
error(msgOrObj, fields) {
381+
this._log('error', 50, msgOrObj, fields);
313382
}
314383

315384
/**
316385
* Log at fatal level (does NOT exit the process)
317-
* @param {object} obj - Object with required msg property
386+
* @param {string|object} msgOrObj - Message string or object with msg property
387+
* @param {object} [fields] - Optional fields to merge
318388
*/
319-
fatal(obj) {
320-
this._log('fatal', 60, obj);
389+
fatal(msgOrObj, fields) {
390+
this._log('fatal', 60, msgOrObj, fields);
321391
}
322392

323393
/**

test/parallel/test-log-basic.js

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const path = require('path');
5252
// Test msg field is required
5353
{
5454
const logger = createLogger();
55+
56+
// Object without msg should throw
5557
assert.throws(() => {
5658
logger.info({ userId: 123 }); // Missing msg
5759
}, {
@@ -63,9 +65,10 @@ const path = require('path');
6365
}, {
6466
code: 'ERR_INVALID_ARG_TYPE',
6567
});
66-
}
6768

68-
// Test child logger context inheritance
69+
// String without second arg should work (no assertion needed, just shouldn't throw)
70+
logger.info('just a message');
71+
}// Test child logger context inheritance
6972
{
7073
const logger = createLogger({ level: 'info' });
7174
const childLogger = logger.child({ requestId: 'abc-123' });
@@ -182,7 +185,8 @@ const path = require('path');
182185
assert.throws(() => {
183186
handler.handle({});
184187
}, {
185-
message: /must be implemented/,
188+
code: 'ERR_METHOD_NOT_IMPLEMENTED',
189+
message: /Handler\.handle\(\) method is not implemented/,
186190
});
187191
}
188192

@@ -208,3 +212,162 @@ const path = require('path');
208212
logger.error({ msg: 'processed' });
209213
assert.strictEqual(handleCalled, true);
210214
}
215+
216+
// Test invalid fields argument
217+
{
218+
const logger = createLogger();
219+
assert.throws(() => {
220+
logger.info('message', 'not an object'); // Second arg must be object
221+
}, {
222+
code: 'ERR_INVALID_ARG_TYPE',
223+
});
224+
}
225+
226+
// Test string message signature
227+
{
228+
const tmpfile = path.join(os.tmpdir(), `test-log-${process.pid}-string.json`);
229+
const handler = new JSONHandler({
230+
stream: fs.openSync(tmpfile, 'w'),
231+
level: 'info',
232+
});
233+
const logger = new Logger({ handler });
234+
235+
logger.info('simple message');
236+
237+
handler.flushSync();
238+
handler.end();
239+
const output = fs.readFileSync(tmpfile, 'utf8');
240+
const parsed = JSON.parse(output.trim());
241+
242+
assert.strictEqual(parsed.msg, 'simple message');
243+
assert.strictEqual(parsed.level, 'info');
244+
245+
fs.unlinkSync(tmpfile);
246+
}
247+
248+
// Test string message with fields
249+
{
250+
const tmpfile = path.join(os.tmpdir(), `test-log-${process.pid}-string-fields.json`);
251+
const handler = new JSONHandler({
252+
stream: fs.openSync(tmpfile, 'w'),
253+
level: 'info',
254+
});
255+
const logger = new Logger({ handler });
256+
257+
logger.info('user login', { userId: 123, ip: '127.0.0.1' });
258+
259+
handler.flushSync();
260+
handler.end();
261+
const output = fs.readFileSync(tmpfile, 'utf8');
262+
const parsed = JSON.parse(output.trim());
263+
264+
assert.strictEqual(parsed.msg, 'user login');
265+
assert.strictEqual(parsed.userId, 123);
266+
assert.strictEqual(parsed.ip, '127.0.0.1');
267+
268+
fs.unlinkSync(tmpfile);
269+
}
270+
271+
// Test Error object serialization
272+
{
273+
const tmpfile = path.join(os.tmpdir(), `test-log-${process.pid}-error.json`);
274+
const handler = new JSONHandler({
275+
stream: fs.openSync(tmpfile, 'w'),
276+
level: 'info',
277+
});
278+
const logger = new Logger({ handler });
279+
280+
const err = new Error('test error');
281+
err.code = 'TEST_ERROR';
282+
logger.error({ msg: 'operation failed', err });
283+
284+
handler.flushSync();
285+
handler.end();
286+
const output = fs.readFileSync(tmpfile, 'utf8');
287+
const parsed = JSON.parse(output.trim());
288+
289+
// Error should be serialized with stack trace
290+
assert.strictEqual(parsed.msg, 'operation failed');
291+
assert.strictEqual(typeof parsed.err, 'object');
292+
assert.strictEqual(parsed.err.message, 'test error');
293+
assert.strictEqual(parsed.err.code, 'TEST_ERROR');
294+
assert(parsed.err.stack);
295+
296+
fs.unlinkSync(tmpfile);
297+
}
298+
299+
// Test Error as first argument
300+
{
301+
const tmpfile = path.join(os.tmpdir(), `test-log-${process.pid}-error-first.json`);
302+
const handler = new JSONHandler({
303+
stream: fs.openSync(tmpfile, 'w'),
304+
level: 'info',
305+
});
306+
const logger = new Logger({ handler });
307+
308+
const err = new Error('boom');
309+
logger.error(err); // Error as first arg
310+
311+
handler.flushSync();
312+
handler.end();
313+
const output = fs.readFileSync(tmpfile, 'utf8');
314+
const parsed = JSON.parse(output.trim());
315+
316+
assert.strictEqual(parsed.msg, 'boom'); // message from error
317+
assert.strictEqual(typeof parsed.err, 'object');
318+
assert(parsed.err.stack);
319+
320+
fs.unlinkSync(tmpfile);
321+
}
322+
323+
// Test child logger with parent fields merge
324+
{
325+
const tmpfile = path.join(os.tmpdir(), `test-log-${process.pid}-child-merge.json`);
326+
const handler = new JSONHandler({
327+
stream: fs.openSync(tmpfile, 'w'),
328+
level: 'info',
329+
fields: { service: 'api' }, // handler fields
330+
});
331+
const logger = new Logger({ handler });
332+
const childLogger = logger.child({ requestId: '123' }); // child bindings
333+
334+
childLogger.info('request processed', { duration: 150 }); // log fields
335+
336+
handler.flushSync();
337+
handler.end();
338+
const output = fs.readFileSync(tmpfile, 'utf8');
339+
const parsed = JSON.parse(output.trim());
340+
341+
// Merge order: handler fields < bindings < log fields
342+
assert.strictEqual(parsed.service, 'api');
343+
assert.strictEqual(parsed.requestId, '123');
344+
assert.strictEqual(parsed.duration, 150);
345+
assert.strictEqual(parsed.msg, 'request processed');
346+
347+
fs.unlinkSync(tmpfile);
348+
}
349+
350+
// Test field override priority
351+
{
352+
const tmpfile = path.join(os.tmpdir(), `test-log-${process.pid}-override.json`);
353+
const handler = new JSONHandler({
354+
stream: fs.openSync(tmpfile, 'w'),
355+
level: 'info',
356+
fields: { env: 'dev', version: '1.0' },
357+
});
358+
const logger = new Logger({ handler });
359+
const childLogger = logger.child({ env: 'staging' });
360+
361+
childLogger.info('test', { env: 'production' });
362+
363+
handler.flushSync();
364+
handler.end();
365+
const output = fs.readFileSync(tmpfile, 'utf8');
366+
const parsed = JSON.parse(output.trim());
367+
368+
// Log fields should override everything
369+
assert.strictEqual(parsed.env, 'production');
370+
assert.strictEqual(parsed.version, '1.0');
371+
372+
fs.unlinkSync(tmpfile);
373+
}

0 commit comments

Comments
 (0)