Skip to content

Commit b53d408

Browse files
committed
implemented serialize symbol
1 parent 14205c6 commit b53d408

File tree

4 files changed

+221
-25
lines changed

4 files changed

+221
-25
lines changed

benchmark/logger/basic-json.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ function main({ n, scenario }) {
4040

4141
case 'string-long': {
4242
// Long string message (100 chars)
43-
const longMsg = 'This is a much longer log message that contains more text to serialize and process during logging operations';
43+
const longMsg = 'This is a much longer log message that contains ' +
44+
'more text to serialize and process during logging operations';
4445
bench.start();
4546
for (let i = 0; i < n; i++) {
4647
logger.info(longMsg);

doc/api/logger.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,78 @@ channels.info.subscribe((record) => {
641641
});
642642
```
643643

644+
## `logger.serialize`
645+
646+
<!-- YAML
647+
added: REPLACEME
648+
-->
649+
650+
* {symbol}
651+
652+
A symbol that objects can implement to define custom serialization behavior
653+
for logging. Similar to [`util.inspect.custom`][].
654+
655+
When an object with a `[serialize]()` method is logged, the logger will call
656+
that method instead of serializing the object directly. This allows objects
657+
to control which properties are included in logs, filtering out sensitive
658+
data like passwords or tokens.
659+
660+
```mjs
661+
import { Logger, JSONConsumer, serialize } from 'node:logger';
662+
663+
class User {
664+
constructor(id, name, password) {
665+
this.id = id;
666+
this.name = name;
667+
this.password = password; // Sensitive!
668+
}
669+
670+
// Define custom serialization
671+
[serialize]() {
672+
return {
673+
id: this.id,
674+
name: this.name,
675+
// password is excluded
676+
};
677+
}
678+
}
679+
680+
const consumer = new JSONConsumer();
681+
consumer.attach();
682+
683+
const logger = new Logger();
684+
const user = new User(1, 'Alice', 'secret123');
685+
686+
logger.info({ msg: 'User logged in', user });
687+
// Output: {"level":"info","time":...,"msg":"User logged in","user":{"id":1,"name":"Alice"}}
688+
// Note: password is not included in the output
689+
```
690+
691+
```cjs
692+
const { Logger, JSONConsumer, serialize } = require('node:logger');
693+
694+
class DatabaseConnection {
695+
constructor(host, user, password) {
696+
this.host = host;
697+
this.user = user;
698+
this.password = password;
699+
}
700+
701+
[serialize]() {
702+
return {
703+
host: this.host,
704+
user: this.user,
705+
connected: this.isConnected,
706+
// password is excluded
707+
};
708+
}
709+
}
710+
```
711+
712+
The `serialize` symbol takes precedence over field-specific serializers.
713+
If an object has both a `[serialize]()` method and a matching serializer
714+
in the logger's `serializers` option, the `[serialize]()` method will be used.
715+
644716
## Examples
645717

646718
### Basic usage
@@ -764,3 +836,4 @@ consumer.attach();
764836

765837
[RFC 5424]: https://www.rfc-editor.org/rfc/rfc5424.html
766838
[`logger.<level>.enabled`]: #loggerlevelenabled
839+
[`util.inspect.custom`]: util.md#utilinspectcustom

lib/logger.js

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,19 @@ const {
77
ObjectHasOwn,
88
ObjectKeys,
99
SafeSet,
10+
SymbolFor,
1011
} = primordials;
1112

1213
const { isNativeError } = require('internal/util/types');
1314

15+
/**
16+
* Symbol for custom serialization.
17+
* Objects can implement this method to control how they are serialized in logs.
18+
* Similar to util.inspect.custom.
19+
* @type {symbol}
20+
*/
21+
const serialize = SymbolFor('nodejs.logger.serialize');
22+
1423
const {
1524
codes: {
1625
ERR_INVALID_ARG_TYPE,
@@ -219,7 +228,6 @@ class Logger {
219228
#bindings;
220229
#bindingsStr; // Pre-serialized bindings JSON string
221230
#serializers;
222-
#hasCustomSerializers;
223231

224232
/**
225233
* Create a new Logger instance
@@ -255,16 +263,10 @@ class Logger {
255263
// Add default err serializer (can be overridden)
256264
this.#serializers.err = stdSerializers.err;
257265

258-
// Track if we have custom serializers beyond 'err'
259-
let hasCustom = false;
260266
// Add custom serializers
261267
for (const key in serializers) {
262268
this.#serializers[key] = serializers[key];
263-
if (key !== 'err') {
264-
hasCustom = true;
265-
}
266269
}
267-
this.#hasCustomSerializers = hasCustom;
268270

269271
// Pre-serialize bindings
270272
this.#bindingsStr = this.#serializeBindings(bindings);
@@ -285,11 +287,7 @@ class Logger {
285287

286288
let result = '';
287289
for (const key in bindings) {
288-
const value = bindings[key];
289-
// Apply serializer if exists
290-
const serialized = this.#serializers[key] ?
291-
this.#serializers[key](value) :
292-
value;
290+
const serialized = this.#serializeValue(bindings[key], key);
293291
result += `,"${key}":${JSONStringify(serialized)}`;
294292
}
295293
return result;
@@ -416,6 +414,28 @@ class Logger {
416414
return childLogger;
417415
}
418416

417+
/**
418+
* Serialize a single value using serialize symbol or field serializer
419+
* @param {*} value - Value to serialize
420+
* @param {string} key - Field key for field-specific serializer lookup
421+
* @returns {*} Serialized value
422+
* @private
423+
*/
424+
#serializeValue(value, key) {
425+
// Check for serialize symbol first
426+
if (value !== null && typeof value === 'object' &&
427+
typeof value[serialize] === 'function') {
428+
return value[serialize]();
429+
}
430+
431+
// Apply field-specific serializer if exists
432+
if (this.#serializers[key]) {
433+
return this.#serializers[key](value);
434+
}
435+
436+
return value;
437+
}
438+
419439
/**
420440
* Apply serializers to an object's properties
421441
* @param {object} obj - Object to serialize
@@ -427,20 +447,10 @@ class Logger {
427447
return obj;
428448
}
429449

430-
// Fast path: no custom serializers, return as-is
431-
if (!this.#hasCustomSerializers) {
432-
return obj;
433-
}
434-
435450
const serialized = { __proto__: null };
436-
const serializers = this.#serializers;
437451

438452
for (const key in obj) {
439-
const value = obj[key];
440-
// Apply serializer if exists for this key
441-
serialized[key] = serializers[key] ?
442-
serializers[key](value) :
443-
value;
453+
serialized[key] = this.#serializeValue(obj[key], key);
444454
}
445455

446456
return serialized;
@@ -580,4 +590,5 @@ module.exports = {
580590
LEVELS,
581591
channels,
582592
stdSerializers,
593+
serialize,
583594
};

test/parallel/test-logger-serializers.js

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
require('../common');
55
const assert = require('node:assert');
66
const { describe, it } = require('node:test');
7-
const { Logger, JSONConsumer, stdSerializers } = require('node:logger');
7+
const { Logger, JSONConsumer, stdSerializers, serialize } = require('node:logger');
88
const { Writable } = require('node:stream');
99

1010
// Test helper to capture log output
@@ -201,4 +201,115 @@ describe('Logger serializers', () => {
201201
assert.strictEqual(log.data.secret, undefined);
202202
});
203203
});
204+
205+
describe('serialize symbol', () => {
206+
it('should use serialize symbol for custom object serialization', () => {
207+
const stream = new TestStream();
208+
const consumer = new JSONConsumer({ stream, level: 'info' });
209+
consumer.attach();
210+
211+
class User {
212+
constructor(id, name, password) {
213+
this.id = id;
214+
this.name = name;
215+
this.password = password;
216+
}
217+
218+
[serialize]() {
219+
return { id: this.id, name: this.name };
220+
}
221+
}
222+
223+
const logger = new Logger({ level: 'info' });
224+
const user = new User(123, 'Alice', 'secret123');
225+
226+
logger.info({ msg: 'User logged in', user });
227+
consumer.flushSync();
228+
229+
assert.strictEqual(stream.logs.length, 1);
230+
const log = stream.logs[0];
231+
assert.strictEqual(log.user.id, 123);
232+
assert.strictEqual(log.user.name, 'Alice');
233+
assert.strictEqual(log.user.password, undefined);
234+
});
235+
236+
it('should prefer serialize symbol over field serializer', () => {
237+
const stream = new TestStream();
238+
const consumer = new JSONConsumer({ stream, level: 'info' });
239+
consumer.attach();
240+
241+
class Connection {
242+
constructor(host, password) {
243+
this.host = host;
244+
this.password = password;
245+
}
246+
247+
[serialize]() {
248+
return { host: this.host, type: 'db' };
249+
}
250+
}
251+
252+
const logger = new Logger({
253+
level: 'info',
254+
serializers: {
255+
// This should be ignored when object has serialize symbol
256+
conn: (c) => ({ host: c.host, fromSerializer: true }),
257+
},
258+
});
259+
260+
const conn = new Connection('localhost', 'secret');
261+
logger.info({ msg: 'Connected', conn });
262+
consumer.flushSync();
263+
264+
const log = stream.logs[0];
265+
assert.strictEqual(log.conn.host, 'localhost');
266+
assert.strictEqual(log.conn.type, 'db');
267+
assert.strictEqual(log.conn.fromSerializer, undefined);
268+
assert.strictEqual(log.conn.password, undefined);
269+
});
270+
271+
it('should work with child logger bindings', () => {
272+
const stream = new TestStream();
273+
const consumer = new JSONConsumer({ stream, level: 'info' });
274+
consumer.attach();
275+
276+
class RequestContext {
277+
constructor(id, internalToken) {
278+
this.id = id;
279+
this.internalToken = internalToken;
280+
}
281+
282+
[serialize]() {
283+
return { requestId: this.id };
284+
}
285+
}
286+
287+
const logger = new Logger({ level: 'info' });
288+
const ctx = new RequestContext('req-456', 'internal-secret');
289+
const childLogger = logger.child({ ctx });
290+
291+
childLogger.info({ msg: 'Processing' });
292+
consumer.flushSync();
293+
294+
const log = stream.logs[0];
295+
assert.strictEqual(log.ctx.requestId, 'req-456');
296+
assert.strictEqual(log.ctx.internalToken, undefined);
297+
});
298+
299+
it('should handle objects without serialize symbol normally', () => {
300+
const stream = new TestStream();
301+
const consumer = new JSONConsumer({ stream, level: 'info' });
302+
consumer.attach();
303+
304+
const logger = new Logger({ level: 'info' });
305+
const data = { foo: 'bar', count: 42 };
306+
307+
logger.info({ msg: 'Plain object', data });
308+
consumer.flushSync();
309+
310+
const log = stream.logs[0];
311+
assert.strictEqual(log.data.foo, 'bar');
312+
assert.strictEqual(log.data.count, 42);
313+
});
314+
});
204315
});

0 commit comments

Comments
 (0)