Repository: pinojs/pino
Pull request #1883 (2024-01-16 13:11:01)

Add ability to override custom levels comparison

obrus-corcentric: > A few things are missing:
> 
> * [ ]  test for the new type (we use `tsd`)
> * [ ]  docs for the new option

Updated doc and types tests

Patch:
diff --git a/docs/api.md b/docs/api.md
index 137afc3..25cd588 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -65,6 +65,32 @@ Additional levels can be added to the instance via the `customLevels` option.
 * See [`customLevels` option](#opt-customlevels)
 
 <a id=opt-customlevels></a>
+
+#### `levelComparison` ("ASC", "DESC", Function)
+
+Default: `ASC`
+
+Use this option to customize levels order.
+In order to be able to define custom levels ordering pass a function which will accept `current` and `expected` values and return `boolean` which shows should `current` level to be shown or not.
+
+```js
+const logger = pino({
+  levelComparison: 'DESC',
+  customLevels: {
+    foo: 20, // `foo` is more valuable than `bar`
+    bar: 10
+  },
+})
+
+// OR
+
+const logger = pino({
+  levelComparison: function(current, expected) {
+    return current >= expected;
+  }
+})
+```
+
 #### `customLevels` (Object)
 
 Default: `undefined`
diff --git a/lib/constants.js b/lib/constants.js
new file mode 100644
index 0000000..f91f731
--- /dev/null
+++ b/lib/constants.js
@@ -0,0 +1,28 @@
+/**
+ * Represents default log level values
+ *
+ * @enum {number}
+ */
+const DEFAULT_LEVELS = {
+  trace: 10,
+  debug: 20,
+  info: 30,
+  warn: 40,
+  error: 50,
+  fatal: 60
+}
+
+/**
+ * Represents sort order direction: `ascending` or `descending`
+ *
+ * @enum {string}
+ */
+const SORTING_ORDER = {
+  ASC: 'ASC',
+  DESC: 'DESC'
+}
+
+module.exports = {
+  DEFAULT_LEVELS,
+  SORTING_ORDER
+}
diff --git a/lib/levels.js b/lib/levels.js
index 3f1acda..5555da4 100644
--- a/lib/levels.js
+++ b/lib/levels.js
@@ -6,21 +6,15 @@ const {
   useOnlyCustomLevelsSym,
   streamSym,
   formattersSym,
-  hooksSym
+  hooksSym,
+  levelCompSym
 } = require('./symbols')
 const { noop, genLog } = require('./tools')
+const { DEFAULT_LEVELS, SORTING_ORDER } = require('./constants')
 
-const levels = {
-  trace: 10,
-  debug: 20,
-  info: 30,
-  warn: 40,
-  error: 50,
-  fatal: 60
-}
 const levelMethods = {
   fatal: (hook) => {
-    const logFatal = genLog(levels.fatal, hook)
+    const logFatal = genLog(DEFAULT_LEVELS.fatal, hook)
     return function (...args) {
       const stream = this[streamSym]
       logFatal.call(this, ...args)
@@ -33,15 +27,15 @@ const levelMethods = {
       }
     }
   },
-  error: (hook) => genLog(levels.error, hook),
-  warn: (hook) => genLog(levels.warn, hook),
-  info: (hook) => genLog(levels.info, hook),
-  debug: (hook) => genLog(levels.debug, hook),
-  trace: (hook) => genLog(levels.trace, hook)
+  error: (hook) => genLog(DEFAULT_LEVELS.error, hook),
+  warn: (hook) => genLog(DEFAULT_LEVELS.warn, hook),
+  info: (hook) => genLog(DEFAULT_LEVELS.info, hook),
+  debug: (hook) => genLog(DEFAULT_LEVELS.debug, hook),
+  trace: (hook) => genLog(DEFAULT_LEVELS.trace, hook)
 }
 
-const nums = Object.keys(levels).reduce((o, k) => {
-  o[levels[k]] = k
+const nums = Object.keys(DEFAULT_LEVELS).reduce((o, k) => {
+  o[DEFAULT_LEVELS[k]] = k
   return o
 }, {})
 
@@ -119,7 +113,39 @@ function getLevel (level) {
 function isLevelEnabled (logLevel) {
   const { values } = this.levels
   const logLevelVal = values[logLevel]
-  return logLevelVal !== undefined && (logLevelVal >= this[levelValSym])
+  return logLevelVal !== undefined && this[levelCompSym](logLevelVal, this[levelValSym])
+}
+
+/**
+ * Determine if the given `current` level is enabled by comparing it
+ * against the current threshold (`expected`).
+ *
+ * @param {SORTING_ORDER} direction comparison direction "ASC" or "DESC"
+ * @param {number} current current log level number representatiton
+ * @param {number} expected threshold value to compare with
+ * @returns {boolean}
+ */
+function compareLevel (direction, current, expected) {
+  if (direction === SORTING_ORDER.DESC) {
+    return current <= expected
+  }
+
+  return current >= expected
+}
+
+/**
+ * Create a level comparison function based on `levelComparison`
+ * it could a default function which compares levels either in "ascending" or "descending" order or custom comparison function
+ *
+ * @param {SORTING_ORDER | Function} levelComparison sort levels order direction or custom comparison function
+ * @returns Function
+ */
+function genLevelComparison (levelComparison) {
+  if (typeof levelComparison === 'string') {
+    return compareLevel.bind(null, levelComparison)
+  }
+
+  return levelComparison
 }
 
 function mappings (customLevels = null, useOnlyCustomLevels = false) {
@@ -139,7 +165,7 @@ function mappings (customLevels = null, useOnlyCustomLevels = false) {
   )
   const values = Object.assign(
     Object.create(Object.prototype, { silent: { value: Infinity } }),
-    useOnlyCustomLevels ? null : levels,
+    useOnlyCustomLevels ? null : DEFAULT_LEVELS,
     customLevels
   )
   return { labels, values }
@@ -160,7 +186,7 @@ function assertDefaultLevelFound (defaultLevel, customLevels, useOnlyCustomLevel
 
   const labels = Object.assign(
     Object.create(Object.prototype, { silent: { value: Infinity } }),
-    useOnlyCustomLevels ? null : levels,
+    useOnlyCustomLevels ? null : DEFAULT_LEVELS,
     customLevels
   )
   if (!(defaultLevel in labels)) {
@@ -180,6 +206,25 @@ function assertNoLevelCollisions (levels, customLevels) {
   }
 }
 
+/**
+ * Validates whether `levelComparison` is correct
+ *
+ * @throws Error
+ * @param {SORTING_ORDER | Function} levelComparison - value to validate
+ * @returns
+ */
+function assertLevelComparison (levelComparison) {
+  if (typeof levelComparison === 'function') {
+    return
+  }
+
+  if (typeof levelComparison === 'string' && Object.values(SORTING_ORDER).includes(levelComparison)) {
+    return
+  }
+
+  throw new Error('Levels comparison should be one of "ASC", "DESC" or "function" type')
+}
+
 module.exports = {
   initialLsCache,
   genLsCache,
@@ -188,7 +233,8 @@ module.exports = {
   setLevel,
   isLevelEnabled,
   mappings,
-  levels,
   assertNoLevelCollisions,
-  assertDefaultLevelFound
+  assertDefaultLevelFound,
+  genLevelComparison,
+  assertLevelComparison
 }
diff --git a/lib/multistream.js b/lib/multistream.js
index 48ea7a5..4de6ab6 100644
--- a/lib/multistream.js
+++ b/lib/multistream.js
@@ -1,16 +1,16 @@
 'use strict'
 
 const metadata = Symbol.for('pino.metadata')
-const { levels } = require('./levels')
+const { DEFAULT_LEVELS } = require('./constants')
 
-const DEFAULT_INFO_LEVEL = levels.info
+const DEFAULT_INFO_LEVEL = DEFAULT_LEVELS.info
 
 function multistream (streamsArray, opts) {
   let counter = 0
   streamsArray = streamsArray || []
   opts = opts || { dedupe: false }
 
-  const streamLevels = Object.create(levels)
+  const streamLevels = Object.create(DEFAULT_LEVELS)
   streamLevels.silent = Infinity
   if (opts.levels && typeof opts.levels === 'object') {
     Object.keys(opts.levels).forEach(i => {
diff --git a/lib/symbols.js b/lib/symbols.js
index 08c0e88..69f1a9d 100644
--- a/lib/symbols.js
+++ b/lib/symbols.js
@@ -3,6 +3,7 @@
 const setLevelSym = Symbol('pino.setLevel')
 const getLevelSym = Symbol('pino.getLevel')
 const levelValSym = Symbol('pino.levelVal')
+const levelCompSym = Symbol('pino.levelComp')
 const useLevelLabelsSym = Symbol('pino.useLevelLabels')
 const useOnlyCustomLevelsSym = Symbol('pino.useOnlyCustomLevels')
 const mixinSym = Symbol('pino.mixin')
@@ -42,6 +43,7 @@ module.exports = {
   setLevelSym,
   getLevelSym,
   levelValSym,
+  levelCompSym,
   useLevelLabelsSym,
   mixinSym,
   lsCacheSym,
diff --git a/pino.d.ts b/pino.d.ts
index 988a2b8..5de2201 100644
--- a/pino.d.ts
+++ b/pino.d.ts
@@ -354,6 +354,12 @@ declare namespace pino {
          * The keys of the object correspond the namespace of the log level, and the values should be the numerical value of the level.
          */
         customLevels?: { [level in CustomLevels]: number };
+        /**
+         *  Use this option to define custom comparison of log levels.
+         *  Usefull to compare custom log levels or non-standard level values.
+         *  Default: "ASC"
+         */
+        levelComparison?: "ASC" | "DESC" | ((current: number, expected: number) => boolean);
         /**
          * Use this option to only use defined `customLevels` and omit Pino's levels.
          * Logger's default `level` must be changed to a value in `customLevels` in order to use `useOnlyCustomLevels`
@@ -799,7 +805,7 @@ declare function pino<CustomLevels extends string = never>(optionsOrStream?: Log
  * relative protocol is enabled. Default: process.stdout
  * @returns a new logger instance.
  */
-declare function pino<CustomLevels extends string = never>(options: LoggerOptions<CustomLevels>, stream: DestinationStream): Logger<CustomLevels>;
+declare function pino<CustomLevels extends string = never>(options: LoggerOptions<CustomLevels>, stream?: DestinationStream | undefined): Logger<CustomLevels>;
 
 
 // Pass through all the top-level exports, allows `import {version} from "pino"`
@@ -853,4 +859,5 @@ export { pino as default, pino };
 // Export just the type side of the namespace as "P", allows
 // `import {P} from "pino"; const log: P.Logger;`.
 // (Legacy support for early 7.x releases, remove in 8.x.)
-export type { pino as P };
+    export type { pino as P };
+
diff --git a/pino.js b/pino.js
index 63500e4..13f1221 100644
--- a/pino.js
+++ b/pino.js
@@ -8,7 +8,8 @@ const time = require('./lib/time')
 const proto = require('./lib/proto')
 const symbols = require('./lib/symbols')
 const { configure } = require('safe-stable-stringify')
-const { assertDefaultLevelFound, mappings, genLsCache, levels } = require('./lib/levels')
+const { assertDefaultLevelFound, mappings, genLsCache, genLevelComparison, assertLevelComparison } = require('./lib/levels')
+const { DEFAULT_LEVELS, SORTING_ORDER } = require('./lib/constants')
 const {
   createArgsNormalizer,
   asChindings,
@@ -36,6 +37,7 @@ const {
   errorKeySym,
   nestedKeySym,
   mixinSym,
+  levelCompSym,
   useOnlyCustomLevelsSym,
   formattersSym,
   hooksSym,
@@ -49,7 +51,8 @@ const hostname = os.hostname()
 const defaultErrorSerializer = stdSerializers.err
 const defaultOptions = {
   level: 'info',
-  levels,
+  levelComparison: SORTING_ORDER.ASC,
+  levels: DEFAULT_LEVELS,
   messageKey: 'msg',
   errorKey: 'err',
   nestedKey: null,
@@ -97,6 +100,7 @@ function pino (...args) {
     name,
     level,
     customLevels,
+    levelComparison,
     mixin,
     mixinMergeStrategy,
     useOnlyCustomLevels,
@@ -157,8 +161,12 @@ function pino (...args) {
   assertDefaultLevelFound(level, customLevels, useOnlyCustomLevels)
   const levels = mappings(customLevels, useOnlyCustomLevels)
 
+  assertLevelComparison(levelComparison)
+  const levelCompFunc = genLevelComparison(levelComparison)
+
   Object.assign(instance, {
     levels,
+    [levelCompSym]: levelCompFunc,
     [useOnlyCustomLevelsSym]: useOnlyCustomLevels,
     [streamSym]: stream,
     [timeSym]: time,


Test patch:
diff --git a/test/is-level-enabled.test.js b/test/is-level-enabled.test.js
index 8df8b88..29c2bab 100644
--- a/test/is-level-enabled.test.js
+++ b/test/is-level-enabled.test.js
@@ -3,41 +3,183 @@
 const { test } = require('tap')
 const pino = require('../')
 
-test('can check if current level enabled', async ({ equal }) => {
-  const log = pino({ level: 'debug' })
-  equal(true, log.isLevelEnabled('debug'))
-})
+const descLevels = {
+  trace: 60,
+  debug: 50,
+  info: 40,
+  warn: 30,
+  error: 20,
+  fatal: 10
+}
 
-test('can check if level enabled after level set', async ({ equal }) => {
-  const log = pino()
-  equal(false, log.isLevelEnabled('debug'))
-  log.level = 'debug'
-  equal(true, log.isLevelEnabled('debug'))
-})
+const ascLevels = {
+  trace: 10,
+  debug: 20,
+  info: 30,
+  warn: 40,
+  error: 50,
+  fatal: 60
+}
+
+test('Default levels suite', ({ test, end }) => {
+  test('can check if current level enabled', async ({ equal }) => {
+    const log = pino({ level: 'debug' })
+    equal(true, log.isLevelEnabled('debug'))
+  })
+
+  test('can check if level enabled after level set', async ({ equal }) => {
+    const log = pino()
+    equal(false, log.isLevelEnabled('debug'))
+    log.level = 'debug'
+    equal(true, log.isLevelEnabled('debug'))
+  })
+
+  test('can check if higher level enabled', async ({ equal }) => {
+    const log = pino({ level: 'debug' })
+    equal(true, log.isLevelEnabled('error'))
+  })
+
+  test('can check if lower level is disabled', async ({ equal }) => {
+    const log = pino({ level: 'error' })
+    equal(false, log.isLevelEnabled('trace'))
+  })
+
+  test('ASC: can check if child has current level enabled', async ({ equal }) => {
+    const log = pino().child({}, { level: 'debug' })
+    equal(true, log.isLevelEnabled('debug'))
+    equal(true, log.isLevelEnabled('error'))
+    equal(false, log.isLevelEnabled('trace'))
+  })
 
-test('can check if higher level enabled', async ({ equal }) => {
-  const log = pino({ level: 'debug' })
-  equal(true, log.isLevelEnabled('error'))
+  test('can check if custom level is enabled', async ({ equal }) => {
+    const log = pino({
+      customLevels: { foo: 35 },
+      level: 'debug'
+    })
+    equal(true, log.isLevelEnabled('foo'))
+    equal(true, log.isLevelEnabled('error'))
+    equal(false, log.isLevelEnabled('trace'))
+  })
+
+  end()
 })
 
-test('can check if lower level is disabled', async ({ equal }) => {
-  const log = pino({ level: 'error' })
-  equal(false, log.isLevelEnabled('trace'))
+test('Ascending levels suite', ({ test, end }) => {
+  const customLevels = ascLevels
+  const levelComparison = 'ASC'
+
+  test('can check if current level enabled', async ({ equal }) => {
+    const log = pino({ level: 'debug', levelComparison, customLevels, useOnlyCustomLevels: true })
+    equal(true, log.isLevelEnabled('debug'))
+  })
+
+  test('can check if level enabled after level set', async ({ equal }) => {
+    const log = pino({ levelComparison, customLevels, useOnlyCustomLevels: true })
+    equal(false, log.isLevelEnabled('debug'))
+    log.level = 'debug'
+    equal(true, log.isLevelEnabled('debug'))
+  })
+
+  test('can check if higher level enabled', async ({ equal }) => {
+    const log = pino({ level: 'debug', levelComparison, customLevels, useOnlyCustomLevels: true })
+    equal(true, log.isLevelEnabled('error'))
+  })
+
+  test('can check if lower level is disabled', async ({ equal }) => {
+    const log = pino({ level: 'error', customLevels, useOnlyCustomLevels: true })
+    equal(false, log.isLevelEnabled('trace'))
+  })
+
+  test('can check if child has current level enabled', async ({ equal }) => {
+    const log = pino().child({ levelComparison, customLevels, useOnlyCustomLevels: true }, { level: 'debug' })
+    equal(true, log.isLevelEnabled('debug'))
+    equal(true, log.isLevelEnabled('error'))
+    equal(false, log.isLevelEnabled('trace'))
+  })
+
+  test('can check if custom level is enabled', async ({ equal }) => {
+    const log = pino({
+      levelComparison,
+      useOnlyCustomLevels: true,
+      customLevels: { foo: 35, ...customLevels },
+      level: 'debug'
+    })
+    equal(true, log.isLevelEnabled('foo'))
+    equal(true, log.isLevelEnabled('error'))
+    equal(false, log.isLevelEnabled('trace'))
+  })
+
+  end()
 })
 
-test('can check if child has current level enabled', async ({ equal }) => {
-  const log = pino().child({}, { level: 'debug' })
-  equal(true, log.isLevelEnabled('debug'))
-  equal(true, log.isLevelEnabled('error'))
-  equal(false, log.isLevelEnabled('trace'))
+test('Descending levels suite', ({ test, end }) => {
+  const customLevels = descLevels
+  const levelComparison = 'DESC'
+
+  test('can check if current level enabled', async ({ equal }) => {
+    const log = pino({ level: 'debug', levelComparison, customLevels, useOnlyCustomLevels: true })
+    equal(true, log.isLevelEnabled('debug'))
+  })
+
+  test('can check if level enabled after level set', async ({ equal }) => {
+    const log = pino({ levelComparison, customLevels, useOnlyCustomLevels: true })
+    equal(false, log.isLevelEnabled('debug'))
+    log.level = 'debug'
+    equal(true, log.isLevelEnabled('debug'))
+  })
+
+  test('can check if higher level enabled', async ({ equal }) => {
+    const log = pino({ level: 'debug', levelComparison, customLevels, useOnlyCustomLevels: true })
+    equal(true, log.isLevelEnabled('error'))
+  })
+
+  test('can check if lower level is disabled', async ({ equal }) => {
+    const log = pino({ level: 'error', levelComparison, customLevels, useOnlyCustomLevels: true })
+    equal(false, log.isLevelEnabled('trace'))
+  })
+
+  test('can check if child has current level enabled', async ({ equal }) => {
+    const log = pino({ levelComparison, customLevels, useOnlyCustomLevels: true }).child({}, { level: 'debug' })
+    equal(true, log.isLevelEnabled('debug'))
+    equal(true, log.isLevelEnabled('error'))
+    equal(false, log.isLevelEnabled('trace'))
+  })
+
+  test('can check if custom level is enabled', async ({ equal }) => {
+    const log = pino({
+      levelComparison,
+      customLevels: { foo: 35, ...customLevels },
+      useOnlyCustomLevels: true,
+      level: 'debug'
+    })
+    equal(true, log.isLevelEnabled('foo'))
+    equal(true, log.isLevelEnabled('error'))
+    equal(false, log.isLevelEnabled('trace'))
+  })
+
+  end()
 })
 
-test('can check if custom level is enabled', async ({ equal }) => {
-  const log = pino({
-    customLevels: { foo: 35 },
-    level: 'debug'
+test('Custom levels comparison', async ({ test, end }) => {
+  test('Custom comparison returns true cause level is enabled', async ({ equal }) => {
+    const log = pino({ level: 'error', levelComparison: () => true })
+    equal(true, log.isLevelEnabled('debug'))
+  })
+
+  test('Custom comparison returns false cause level is disabled', async ({ equal }) => {
+    const log = pino({ level: 'error', levelComparison: () => false })
+    equal(false, log.isLevelEnabled('debug'))
   })
-  equal(true, log.isLevelEnabled('foo'))
-  equal(true, log.isLevelEnabled('error'))
-  equal(false, log.isLevelEnabled('trace'))
+
+  test('Custom comparison returns true cause child level is enabled', async ({ equal }) => {
+    const log = pino({ levelComparison: () => true }).child({ level: 'error' })
+    equal(true, log.isLevelEnabled('debug'))
+  })
+
+  test('Custom comparison returns false cause child level is disabled', async ({ equal }) => {
+    const log = pino({ levelComparison: () => false }).child({ level: 'error' })
+    equal(false, log.isLevelEnabled('debug'))
+  })
+
+  end()
 })
diff --git a/test/types/pino.test-d.ts b/test/types/pino.test-d.ts
index ee78e3d..740f7fd 100644
--- a/test/types/pino.test-d.ts
+++ b/test/types/pino.test-d.ts
@@ -1,7 +1,7 @@
-import P, { pino } from "../../";
 import { IncomingMessage, ServerResponse } from "http";
 import { Socket } from "net";
-import { expectError, expectType } from 'tsd'
+import { expectError, expectType } from 'tsd';
+import P, { pino } from "../../";
 import Logger = P.Logger;
 
 const log = pino();
@@ -107,6 +107,8 @@ pino({
     },
 });
 
+pino({}, undefined);
+
 pino({ base: null });
 if ("pino" in log) console.log(`pino version: ${log.pino}`);
 
@@ -424,3 +426,16 @@ expectError(childLogger2.doesntExist);
 expectError(pino({
   onChild: (child) => { const a = child.doesntExist; }
 }, process.stdout));
+
+const pinoWithoutLevelsSorting = pino({});
+const pinoWithDescSortingLevels = pino({ levelComparison: 'DESC' });
+const pinoWithAscSortingLevels = pino({ levelComparison: 'ASC' });
+const pinoWithCustomSortingLevels = pino({ levelComparison: () => false });
+// with wrong level comparison direction
+expectError(pino({ levelComparison: 'SOME'}), process.stdout);
+// with wrong level comparison type
+expectError(pino({ levelComparison: 123}), process.stdout);
+// with wrong custom level comparison return type
+expectError(pino({ levelComparison: () => null }), process.stdout);
+expectError(pino({ levelComparison: () => 1 }), process.stdout);
+expectError(pino({ levelComparison: () => 'string' }), process.stdout);
\ No newline at end of file