Skip to content

Conversation

@mertcanaltin
Copy link
Member

@mertcanaltin mertcanaltin commented Oct 28, 2025

for: #49296 (comment)

I try a draft development for new logger api, and i try create some benchmark for pino and node:logger package

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/performance

@nodejs-github-bot nodejs-github-bot added the needs-ci PRs that need a full CI run. label Oct 28, 2025
@mertcanaltin mertcanaltin changed the title lib: added logger package in node core lib: added logger api in node core Oct 28, 2025
@mertcanaltin mertcanaltin changed the title lib: added logger api in node core [WIP] lib: added logger api in node core Oct 28, 2025
lib/logger.js Outdated
constructor(options = {}) {
validateObject(options, 'options');
const {
handler = new JSONHandler(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than passing the handler directly in like this, it would be worth considering a design more like swift-log where there is a separate interface for indirectly attaching a consumer. That way it doesn't need to be the responsibility of each place a logger is constructed to decide where the logs are being shipped. I had planned on working on structured logging myself at some point, using diagnostics_channel to manage dispatching log events to consumers, and even support multiple consumers. In a lot of apps you might want console output but also to be writing logs to other places like log search services.

My original plan was to have separate channels for each log level, that way events of log levels not being listened to could be ignored entirely, but also it would enable each consumer to decide their own log level independently--a console logger might want to only listen to info+ levels while an attached diagnostics tool might want to see everything.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 sound good, thanks for detail, I will apply this plan.

@mertcanaltin mertcanaltin requested a review from Qard October 29, 2025 14:16
@mertcanaltin
Copy link
Member Author

mertcanaltin commented Oct 29, 2025

thats my first performance results:

I think need improvement for child logger area, but ı'm so happy because other results it looks nice.
cc @mcollina @Qard @trentm @jsumners

node:logger vs pino

➜ node git:(mert/create-logger-api/node-core) ✗ ./node benchmark/logger/vs-pino.js n=100000
logger/vs-pino.js scenario="simple" logger="node-logger" n=100000: 5,240,540.823813018
logger/vs-pino.js scenario="child" logger="node-logger" n=100000: 2,635,847.7027229806
logger/vs-pino.js scenario="disabled" logger="node-logger" n=100000: 159,436,487.67795104
logger/vs-pino.js scenario="fields" logger="node-logger" n=100000: 3,619,336.304205216
logger/vs-pino.js scenario="simple" logger="pino" n=100000: 3,398,489.9761368227
logger/vs-pino.js scenario="child" logger="pino" n=100000: 4,489,799.803418606
logger/vs-pino.js scenario="disabled" logger="pino" n=100000: 119,772,384.56038144
logger/vs-pino.js scenario="fields" logger="pino" n=100000: 1,257,930.8609750536

@mertcanaltin
Copy link
Member Author

mertcanaltin commented Oct 29, 2025

I now learn this feat in Pino

pinojs/pino#2281

I will try add in node:logger

@mcollina
Copy link
Member

This will require support for serializers

@mertcanaltin
Copy link
Member Author

mertcanaltin commented Oct 30, 2025

This will require support for serializers

I wonder, should we name them like Pino does (built-in serializers), or go with something like standardSerializers ?

@mcollina
Copy link
Member

mcollina commented Nov 8, 2025

This will require support for serializers

I wonder, should we name them like Pino does (built-in serializers), or go with something like standardSerializers ?

Follow pino and we’ll change it

@mertcanaltin
Copy link
Member Author

mertcanaltin commented Nov 10, 2025

This will require support for serializers

I wonder, should we name them like Pino does (built-in serializers), or go with something like standardSerializers ?

Follow pino and we’ll change it

I tryed serializer implement for logger, and some bench result repaired

and fields and simple are experiencing a decline; I will try to resolve these

fields: 3.62M → 2.16M (-40%)
simple: 5.24M → 4.87M (-7%)

previously, the results for the child logger were quite slow at around 70%, but the new results have dropped to 18% and have actually improved significantly.

I continue to try new methods.

➜  node git:(mert/create-logger-api/node-core) ✗ ./node benchmark/logger/vs-pino.js n=100000
logger/vs-pino.js scenario="simple" logger="node-logger" n=100000: 4,868,164.032787085
logger/vs-pino.js scenario="child" logger="node-logger" n=100000: 3,894,327.425314102
logger/vs-pino.js scenario="disabled" logger="node-logger" n=100000: 160,503,080.85663706
logger/vs-pino.js scenario="fields" logger="node-logger" n=100000: 2,157,462.3927336666
logger/vs-pino.js scenario="simple" logger="pino" n=100000: 3,424,706.4418693925
logger/vs-pino.js scenario="child" logger="pino" n=100000: 4,753,595.477010947
logger/vs-pino.js scenario="disabled" logger="pino" n=100000: 122,100,122.10012211
logger/vs-pino.js scenario="fields" logger="pino" n=100000: 1,411,215.99189962
➜  node git:(mert/create-logger-api/node-core) ✗ 

@mertcanaltin
Copy link
Member Author

mertcanaltin commented Nov 10, 2025

This will require support for serializers

I wonder, should we name them like Pino does (built-in serializers), or go with something like standardSerializers ?

Follow pino and we’ll change it

I tryed serializer implement for logger, and some bench result repaired

and fields and simple are experiencing a decline; I will try to resolve these

fields: 3.62M → 2.16M (-40%) simple: 5.24M → 4.87M (-7%)

previously, the results for the child logger were quite slow at around 70%, but the new results have dropped to 18% and have actually improved significantly.

I continue to try new methods.

➜  node git:(mert/create-logger-api/node-core) ✗ ./node benchmark/logger/vs-pino.js n=100000
logger/vs-pino.js scenario="simple" logger="node-logger" n=100000: 4,868,164.032787085
logger/vs-pino.js scenario="child" logger="node-logger" n=100000: 3,894,327.425314102
logger/vs-pino.js scenario="disabled" logger="node-logger" n=100000: 160,503,080.85663706
logger/vs-pino.js scenario="fields" logger="node-logger" n=100000: 2,157,462.3927336666
logger/vs-pino.js scenario="simple" logger="pino" n=100000: 3,424,706.4418693925
logger/vs-pino.js scenario="child" logger="pino" n=100000: 4,753,595.477010947
logger/vs-pino.js scenario="disabled" logger="pino" n=100000: 122,100,122.10012211
logger/vs-pino.js scenario="fields" logger="pino" n=100000: 1,411,215.99189962
➜  node git:(mert/create-logger-api/node-core) ✗ 

I inspected pino and I learn some patterns, than I applied this commit and new results! 1ededc7

I used this patterns

removed the cost of serializing bindings in each log for the child logger,
skipped unnecessary serializer checks,
used direct string concatenation instead of object merging,
sped up type checking

simple: 6.06M vs 3.48M ops/s (+74% faster)
child: 5.76M vs 4.41M ops/s (+31% faster)
disabled: 174M vs 146M ops/s (+19% faster)
fields: 2.13M vs 1.36M ops/s (+56% faster)

➜  node git:(mert/create-logger-api/node-core) ✗ ./node benchmark/logger/vs-pino.js n=100000
logger/vs-pino.js scenario="simple" logger="node-logger" n=100000: 6,062,182.962986493
logger/vs-pino.js scenario="child" logger="node-logger" n=100000: 5,758,903.394222795
logger/vs-pino.js scenario="disabled" logger="node-logger" n=100000: 174,026,539.04720467
logger/vs-pino.js scenario="fields" logger="node-logger" n=100000: 2,126,059.37552321
logger/vs-pino.js scenario="simple" logger="pino" n=100000: 3,477,918.037575009
logger/vs-pino.js scenario="child" logger="pino" n=100000: 4,407,389.658686015
logger/vs-pino.js scenario="disabled" logger="pino" n=100000: 145,551,509.22359914
logger/vs-pino.js scenario="fields" logger="pino" n=100000: 1,363,125.197883181
➜  node git:(mert/create-logger-api/node-core) ✗ 

@mertcanaltin
Copy link
Member Author

mertcanaltin commented Nov 20, 2025

hello @mcollina do you any comments or suggestions for this end commits, I'm curious 🙏 .

@mertcanaltin mertcanaltin marked this pull request as ready for review November 29, 2025 14:16
@codecov
Copy link

codecov bot commented Nov 29, 2025

Codecov Report

❌ Patch coverage is 88.31522% with 86 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.52%. Comparing base (6274eb7) to head (ad43d25).
⚠️ Report is 189 commits behind head on main.

Files with missing lines Patch % Lines
lib/logger.js 87.70% 81 Missing ⚠️
lib/internal/logger/serializers.js 93.50% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #60468      +/-   ##
==========================================
- Coverage   88.55%   88.52%   -0.03%     
==========================================
  Files         703      706       +3     
  Lines      208291   209495    +1204     
  Branches    40170    40372     +202     
==========================================
+ Hits       184443   185453    +1010     
- Misses      15874    16057     +183     
- Partials     7974     7985      +11     
Files with missing lines Coverage Δ
lib/internal/logger/serializers.js 93.50% <93.50%> (ø)
lib/logger.js 87.70% <87.70%> (ø)

... and 85 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mertcanaltin mertcanaltin requested a review from a team as a code owner November 30, 2025 17:53
@mertcanaltin mertcanaltin force-pushed the mert/create-logger-api/node-core branch from 259652e to f5b0108 Compare November 30, 2025 17:57
@mertcanaltin mertcanaltin force-pushed the mert/create-logger-api/node-core branch from f5b0108 to 429359b Compare November 30, 2025 18:01
* @returns {boolean}
*/
enabled(level) {
return LEVELS[level] >= this.#levelValue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this throw an explicit validation error if the provided level is unknown? (e.g., typo, wrong case, accidental leading/trailing space, etc.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing this out! You're absolutely right if an unknown log level is provided (for example, due to a typo, incorrect capitalization, or extra spaces), it's much safer to throw an explicit error rather than silently returning an unexpected result.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally, a logger should not throw any errors once it has been initialized. What are the use cases where this error could be thrown?

Copy link
Member Author

@mertcanaltin mertcanaltin Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing this out! You're absolutely right if an unknown log level is provided (for example, due to a typo, incorrect capitalization, or extra spaces), it's much safer to throw an explicit error rather than silently returning an unexpected result.

Yes, I was mistaken here. Doing this would disrupt the flow. I'm wondering whether returning false here is appropriate.

@jsumners @yvele

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jsumners @mertcanaltin

My concern is mostly about developer experience: enabled() is often used to guard expensive computations, for example:

if (logger.enabled('debug')) {
  computeExtraDebugInfo();
}

If someone makes a typo or uses the wrong case (e.g., logger.enabled('DEBUG') or even logger.enabled(20)) the call will silently return false. This means the guarded code is skipped without any indication that something is wrong, which can be quite hard to debug.

Given that log levels are a fixed, finite set, failing fast (or at least surfacing a warning) helps catch mistakes early in development instead of letting them silently pass.

Maybe throwing isn’t ideal at runtime, but completely silent failure is also risky.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I am open to the idea of mentioning this in the document.

Copy link
Member

@Qard Qard Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a situation where it's probably best to avoid dynamic inputs. Having enabledDebug() is a strictly safer pattern because it will correctly throw when you try to call that for a non-existent level and will correctly never throw when using one that does exist. People might not like the greater API surface area, but it's certainly safer than trying to reduce surface area where it doesn't actually make sense to do so.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fun fact: The logger I created for my company initially had isDebugEnabled(), isWarningEnabled(), etc., and only recently I switched to enabled(level) after seeing this PR and noticing it’s becoming standard.

I imagine enabled(level) was chosen to allow for dynamic log levels. But if the set of levels is truly fixed, the static methods might be a pragmatic choice: They won’t throw and they avoid bad user input for critical logging.

So my question is: Are log levels considered fixed? 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I greatly regret adding custom levels to Pino. I strongly recommend skipping them here.

Copy link
Member Author

@mertcanaltin mertcanaltin Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you both for the discussion, I'll keep the current behavior where enabled() returns false for unknown levels. I agree with @jsumners that we shouldn't add custom levels, and the current implementation is simple and safe. I'll document this behavior clearly in the API docs 83a12b7.

lib/logger.js Outdated
Comment on lines 198 to 248
validateString(level, 'options.level');
if (!LEVELS[level]) {
throw new ERR_INVALID_ARG_VALUE('options.level', level,
`must be one of: ${LEVEL_NAMES.join(', ')}`);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still doesn't use validateOneOf

mertcanaltin and others added 9 commits December 24, 2025 22:38
Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
@mertcanaltin mertcanaltin requested a review from anonrig December 26, 2025 18:43
@mertcanaltin mertcanaltin force-pushed the mert/create-logger-api/node-core branch 2 times, most recently from 23955ed to 76b0742 Compare December 30, 2025 17:44
Copy link
Member

@Qard Qard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the general direction is good. I mostly have a few internal detail concerns. For API surface level, I think it's generally fine, but I'm not a huge fan of the logger.enabled(arbitraryString) thing and I'm also a bit confused by having level reconfigurable per-child. Why is that exactly? I'm unclear on the value of having independent log levels per child.

* } }} req - HTTP request
* @returns {{ method: string, url: string, headers: object, remoteAddress?: string, remotePort?: string }}
*/
function req(req) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to consider a similar pattern to https://nodejs.org/api/util.html#utilinspectcustom for these type-specific serializers.

Copy link
Member Author

@mertcanaltin mertcanaltin Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for suggestion, I will try this pattern

@mertcanaltin mertcanaltin requested a review from Qard January 5, 2026 20:28
@mertcanaltin
Copy link
Member Author

mertcanaltin commented Jan 5, 2026

I think the general direction is good. I mostly have a few internal detail concerns. For API surface level, I think it's generally fine, but I'm not a huge fan of the logger.enabled(arbitraryString) thing and I'm also a bit confused by having level reconfigurable per-child. Why is that exactly? I'm unclear on the value of having independent log levels per child.

Thanks for the review @Qard!

logger.enabled(arbitraryString)

You mentioned earlier preferring enabledDebug()(#60468 (comment)), enabledInfo() style methods. I'm open to this change it would be safer since typos would throw at call time rather than silently returning false. Would you like me to add level-specific methods like:

logger.enabledTrace()
logger.enabledDebug()
logger.enabledInfo()

Or should we keep both approaches?

Independent log levels per child

This pattern comes from Pino and has some real-world use cases:

Module-specific debugging: Main logger at info, but a problematic module's child at debug for troubleshooting
Verbose subsystems: HTTP request logger at debug, but database logger at warn to reduce noise
Gradual rollout: Increase verbosity for specific features without flooding all logs

Example:

  const logger = new Logger({ level: 'info' });
  const dbLogger = logger.child({ module: 'db' }, { level: 'warn' });
  const authLogger = logger.child({ module: 'auth' }, { level: 'debug' });

That said, if you think this adds unnecessary complexity, I can remove the level option from child() children would always inherit parent's level.

@TheOneTheOnlyJJ
Copy link

TheOneTheOnlyJJ commented Jan 6, 2026

Or should we keep both approaches?

I suppose the arbitrary string method (as currently implemented) makes sense if new log levels can be added at run-time.

EDIT: Alternatively, change the function so that it (also) accepts number arguments, so that the log level const values (LEVELS.debug, LEVELS.info, etc.) can be used. Recommend the usage of the log level const values with the function in the docs. Keep the existing string functionality for use cases such as runtime user input?

Independent log levels per child

This is a nice feature to have, and it would be unfortunate to have it removed now that it is already implemented. But I am not a maintainer, so I will not comment on it more than this.

@Qard
Copy link
Member

Qard commented Jan 6, 2026

I'm not against keeping the per-child log levels, it just feels a bit weird to me that if I globally set a log level it could then get ignored and set to something else on child loggers. Seems to me like generally when a user configures the log level of the base logger they would want that to apply to ALL loggers descending from it and not just that base logger. I'm not sure how else to achieve the concepts you listed though beyond what some languages do of having logger names and filtering them like DEBUG=thing1,thing2.

As for the enabled(...) method, I personally would have gone with something like having a getter directly on the individual log level functions, so log.warn(...) has log.warn.enabled or something like that. Though I understand that some people don't like putting properties on functions/methods. Something like enabled${level}() is a reasonable alternative, though this really feels more like a getter/accessor thing than something that should be a function/method. For the idea of accepting numbers instead and using the values like LEVELS.info there, that feels moderately better than just the string in some ways, but moderately worse in others, and doesn't really address what I think is the most important issue of accepting any arbitrary value. Though I suppose with the enabled getter design I would have gone with it's not really all that different as you can still access a non-existent property on something and it won't fail until you try to go another level, but at least logger.doesNotExist.enabled would certainly fail for being two levels deep. 🤷🏻

@mertcanaltin
Copy link
Member Author

mertcanaltin commented Jan 6, 2026

Thanks for comments @TheOneTheOnlyJJ & @Qard

Implemented logger..enabled getter ad43d25:

if (logger.debug.enabled) {
  logger.debug('expensive', computeData());
}
// Typos throw: logger.debg.enabled → TypeError

For per-child levels, keeping it as-is with clear docs - the use cases (module-specific debugging) seem valuable enough.

@Qard
Copy link
Member

Qard commented Jan 7, 2026

I thought a bit more about the per-child log level thing. Do you think it would be reasonable to suggest in docs that module authors should NOT use it in their own code and that it should generally only be used in application code for isolating particular components or direct dependencies?

I'm just thinking it would be annoying to have a transitive dependency not follow the log levels an application developer specifies. I would think the typical pattern with loggers, at least as I have seen before, would be to construct a child externally to the library you want to use the logger, that way you can control the behaviour external to that module? But then with a library calling another library it may construct another child but should just inherit the logger level it has been given by the application developer?

What do you think? Should we document the feature as something generally only application code should use?

Comment on lines +75 to +77
validateString(level, 'options.level');

validateOneOf(level, 'options.level', LEVEL_NAMES);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't validateOneOf imply validateString, since each of LEVEL_NAMES is a string type?

Comment on lines +88 to +90
if (!ObjectHasOwn(LEVELS, level)) {
return false;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How fast is this check?

If it's slow, we can probably optimize it by using a #enabled helper.

i.e. .enabled() calls:

Suggested change
if (!ObjectHasOwn(LEVELS, level)) {
return false;
}
if (!ObjectHasOwn(LEVELS, level)) {
return false;
}
this.#enabled();

Whereas internal tools, which know a given level is in the LEVELS list, would just call this.#enabled directly?

Comment on lines +166 to +168
let json = '{"level":"' + record.level + '",' +
'"time":' + record.time + ',' +
'"msg":"' + record.msg + '"';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't we building the JSON like an object, and then using JSONStringify on the entire object? I feel like it'll be a lot more readable.

Comment on lines +246 to +247
validateString(level, 'options.level');
validateOneOf(level, 'options.level', LEVEL_NAMES);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Comment on lines +267 to +275
let hasCustom = false;
// Add custom serializers
for (const key in serializers) {
this.#serializers[key] = serializers[key];
if (key !== 'err') {
hasCustom = true;
}
}
this.#hasCustomSerializers = hasCustom;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let hasCustom = false;
// Add custom serializers
for (const key in serializers) {
this.#serializers[key] = serializers[key];
if (key !== 'err') {
hasCustom = true;
}
}
this.#hasCustomSerializers = hasCustom;
// Add custom serializers
for (const key in serializers) {
this.#serializers[key] = serializers[key];
if (key !== 'err') {
this.#hasCustomSerializers = true;
}
}

Comment on lines +334 to +342
if (enabled) {
// Create bound log function
fn = (msgOrObj, fields) => {
this.#log(level, methodLevelValue, msgOrObj, fields);
};
} else {
// Use noop for disabled levels
fn = noop;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the log is enabled at a point in the future? i.e. not on creation

Comment on lines +417 to +425
/**
* Check if value is an Error object
* @param {*} value - Value to check
* @returns {boolean} True if value is an Error
* @private
*/
#isError(value) {
return isNativeError(value);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this wrapper needed?

Comment on lines +466 to +468
if (levelValue < this.#levelValue) {
return;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we have a this.#enabled(level) helper?

* @private
*/
#createLogMethod(level, methodLevelValue, loggerLevelValue) {
const enabled = methodLevelValue >= loggerLevelValue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we have a this.#enabled(level) helper?

Comment on lines +582 to +589
/**
* Log a message at trace level
* @param {string|object|Error} msgOrObj - Message or object
* @param {object} [fields] - Optional fields
*/
trace(msgOrObj, fields) {
this.#log('trace', LEVELS.trace, msgOrObj, fields);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these here, if we have a helper to create these functions?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.