Skip to content

Commit 5f1d732

Browse files
committed
logger: address review feedback
1 parent 24fefbd commit 5f1d732

File tree

3 files changed

+123
-65
lines changed

3 files changed

+123
-65
lines changed

doc/api/logger.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,23 @@ consumer.attach();
359359
// Consumer now receives all log records at 'info' level and above
360360
```
361361
362+
### `consumer.detach()`
363+
364+
<!-- YAML
365+
added: REPLACEME
366+
-->
367+
368+
Detaches the consumer from log channels. After calling this method, the consumer
369+
will no longer receive log records.
370+
371+
```js
372+
const consumer = new JSONConsumer({ level: 'info' });
373+
consumer.attach();
374+
// ... later
375+
consumer.detach();
376+
// Consumer no longer receives log records
377+
```
378+
362379
### `consumer.<level>.enabled`
363380
364381
<!-- YAML

lib/logger.js

Lines changed: 26 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const {
55
ObjectAssign,
66
ObjectDefineProperty,
77
ObjectKeys,
8-
SafeSet,
98
SymbolFor,
109
} = primordials;
1110

@@ -76,12 +75,14 @@ function noop() {}
7675
class LogConsumer {
7776
/** @type {number} */
7877
#levelValue;
78+
#handlers;
7979

8080
constructor(options = kEmptyObject) {
8181
validateObject(options, 'options');
8282
const { level = 'info' } = options;
8383
validateOneOf(level, 'options.level', LEVEL_NAMES);
8484
this.#levelValue = LEVELS[level];
85+
this.#handlers = { __proto__: null };
8586

8687
// Setup level-specific enabled properties for typo safety
8788
// Allows consumer.info.enabled instead of consumer.enabled('info')
@@ -98,7 +99,21 @@ class LogConsumer {
9899
attach() {
99100
for (const level of LEVEL_NAMES) {
100101
if (this[level].enabled) {
101-
channels[level].subscribe(this.handle.bind(this));
102+
const handler = this.handle.bind(this);
103+
this.#handlers[level] = handler;
104+
channels[level].subscribe(handler);
105+
}
106+
}
107+
}
108+
109+
/**
110+
* Detach this consumer from log channels
111+
*/
112+
detach() {
113+
for (const level of LEVEL_NAMES) {
114+
if (this.#handlers[level]) {
115+
channels[level].unsubscribe(this.#handlers[level]);
116+
this.#handlers[level] = undefined;
102117
}
103118
}
104119
}
@@ -164,15 +179,13 @@ class JSONConsumer extends LogConsumer {
164179

165180
handle(record) {
166181
// Start building JSON manually
167-
let json = `{"level":"${record.level}","time":${record.time},"msg":"${record.msg}"`;
182+
// record.level is trusted internal value, record.msg is user input and must be escaped
183+
let json = `{"level":"${record.level}","time":${record.time},"msg":${JSONStringify(record.msg)}`;
168184

169185
// Add consumer fields
170186
const consumerFields = this.#fields;
171187
for (const key of ObjectKeys(consumerFields)) {
172-
const value = consumerFields[key];
173-
json += typeof value === 'string' ?
174-
`,"${key}":"${value}"` :
175-
`,"${key}":${JSONStringify(value)}`;
188+
json += `,${JSONStringify(key)}:${JSONStringify(consumerFields[key])}`;
176189
}
177190

178191
// Add pre-serialized bindings
@@ -181,10 +194,7 @@ class JSONConsumer extends LogConsumer {
181194
// Add log fields
182195
const fields = record.fields;
183196
for (const key in fields) {
184-
const value = fields[key];
185-
json += typeof value === 'string' ?
186-
`,"${key}":"${value}"` :
187-
`,"${key}":${JSONStringify(value)}`;
197+
json += `,${JSONStringify(key)}:${JSONStringify(fields[key])}`;
188198
}
189199

190200
json += '}\n';
@@ -285,7 +295,7 @@ class Logger {
285295
const keys = ObjectKeys(bindings);
286296
for (const key of keys) {
287297
const serialized = this.#serializeValue(bindings[key], key);
288-
result += `,"${key}":${JSONStringify(serialized)}`;
298+
result += `,${JSONStringify(key)}:${JSONStringify(serialized)}`;
289299
}
290300
return result;
291301
}
@@ -480,13 +490,8 @@ class Logger {
480490

481491
if (isNativeError(msgOrObj)) {
482492
msg = msgOrObj.message;
483-
// Use err serializer for Error objects
484-
const serializedErr = this.#serializers.err ?
485-
this.#serializers.err(msgOrObj) :
486-
this.#serializeError(msgOrObj);
487-
488493
logFields = {
489-
err: serializedErr,
494+
err: this.#serializers.err(msgOrObj),
490495
};
491496

492497
// Apply serializers to additional fields
@@ -508,17 +513,13 @@ class Logger {
508513
// Apply serializers to object fields
509514
logFields = this.#applySerializers(restFields);
510515

511-
// Special handling for err/error fields
516+
// Re-serialize err/error fields with err serializer for proper Error handling
512517
if (logFields.err && isNativeError(restFields.err)) {
513-
logFields.err = this.#serializers.err ?
514-
this.#serializers.err(restFields.err) :
515-
this.#serializeError(restFields.err);
518+
logFields.err = this.#serializers.err(restFields.err);
516519
}
517520

518521
if (logFields.error && isNativeError(restFields.error)) {
519-
logFields.error = this.#serializers.err ?
520-
this.#serializers.err(restFields.error) :
521-
this.#serializeError(restFields.error);
522+
logFields.error = this.#serializers.err(restFields.error);
522523
}
523524
}
524525

@@ -533,46 +534,6 @@ class Logger {
533534
channel.publish(record);
534535
}
535536

536-
/**
537-
* Serialize Error object for logging (with recursive cause handling)
538-
* @param {object} err - Error object to serialize
539-
* @param {Set} [seen] - Set to track circular references
540-
* @returns {object|string} Serialized error object or '[Circular]'
541-
* @private
542-
*/
543-
#serializeError(err, seen = new SafeSet()) {
544-
if (seen.has(err)) {
545-
return '[Circular]';
546-
}
547-
seen.add(err);
548-
549-
const serialized = {
550-
message: err.message,
551-
name: err.name,
552-
stack: err.stack,
553-
};
554-
555-
if (err.code !== undefined) {
556-
serialized.code = err.code;
557-
}
558-
559-
if (err.cause !== undefined) {
560-
serialized.cause = isNativeError(err.cause) ?
561-
this.#serializeError(err.cause, seen) :
562-
err.cause;
563-
}
564-
565-
// Include additional own error properties (avoid prototype chain)
566-
const keys = ObjectKeys(err);
567-
for (let i = 0; i < keys.length; i++) {
568-
const key = keys[i];
569-
if (serialized[key] === undefined) {
570-
serialized[key] = err[key];
571-
}
572-
}
573-
574-
return serialized;
575-
}
576537
}
577538

578539
module.exports = {

test/parallel/test-logger-serializers.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,4 +354,84 @@ describe('Logger serializers', () => {
354354
assert.strictEqual(log.data.count, 42);
355355
});
356356
});
357+
358+
describe('JSON escaping', () => {
359+
it('should properly escape special characters in msg', () => {
360+
const stream = new TestStream();
361+
const consumer = new JSONConsumer({ stream, level: 'info' });
362+
consumer.attach();
363+
364+
const logger = new Logger({ level: 'info' });
365+
logger.info('My invalid json ", "__proto__": true,');
366+
consumer.flushSync();
367+
368+
assert.strictEqual(stream.logs.length, 1);
369+
const log = stream.logs[0];
370+
assert.strictEqual(log.msg, 'My invalid json ", "__proto__": true,');
371+
assert.strictEqual(log.__proto__, undefined);
372+
});
373+
374+
it('should properly escape special characters in field keys', () => {
375+
const stream = new TestStream();
376+
const consumer = new JSONConsumer({ stream, level: 'info' });
377+
consumer.attach();
378+
379+
const logger = new Logger({ level: 'info' });
380+
logger.info({ msg: 'test', ['"break']: 'value' });
381+
consumer.flushSync();
382+
383+
assert.strictEqual(stream.logs.length, 1);
384+
const log = stream.logs[0];
385+
assert.strictEqual(log['"break'], 'value');
386+
});
387+
388+
it('should properly escape special characters in field values', () => {
389+
const stream = new TestStream();
390+
const consumer = new JSONConsumer({ stream, level: 'info' });
391+
consumer.attach();
392+
393+
const logger = new Logger({ level: 'info' });
394+
logger.info('test', { data: 'value with "quotes" and \nnewline' });
395+
consumer.flushSync();
396+
397+
assert.strictEqual(stream.logs.length, 1);
398+
const log = stream.logs[0];
399+
assert.strictEqual(log.data, 'value with "quotes" and \nnewline');
400+
});
401+
402+
it('should properly escape special characters in bindings keys', () => {
403+
const stream = new TestStream();
404+
const consumer = new JSONConsumer({ stream, level: 'info' });
405+
consumer.attach();
406+
407+
const logger = new Logger({ level: 'info' });
408+
const child = logger.child({ ['"break']: 'bla' });
409+
child.info('test');
410+
consumer.flushSync();
411+
412+
assert.strictEqual(stream.logs.length, 1);
413+
const log = stream.logs[0];
414+
assert.strictEqual(log['"break'], 'bla');
415+
});
416+
});
417+
});
418+
419+
describe('LogConsumer detach', () => {
420+
it('should stop receiving logs after detach', () => {
421+
const stream = new TestStream();
422+
const consumer = new JSONConsumer({ stream, level: 'info' });
423+
consumer.attach();
424+
425+
const logger = new Logger({ level: 'info' });
426+
427+
logger.info('before detach');
428+
consumer.flushSync();
429+
assert.strictEqual(stream.logs.length, 1);
430+
431+
consumer.detach();
432+
433+
logger.info('after detach');
434+
consumer.flushSync();
435+
assert.strictEqual(stream.logs.length, 1); // No new log
436+
});
357437
});

0 commit comments

Comments
 (0)